Skip to content
Merged
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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Two thin cross-cutting layers. Both fail open — telemetry is never a correctne
### Auth — GitHub OAuth (v0.5.0)

- Server-side OAuth code flow (no PKCE — GitHub OAuth Apps don't support it). State in a short-lived httpOnly cookie.
- We request `read:user` and `public_repo` only. Never `repo` (we do not need private data) and never `admin:*`.
- We request `read:user` only (tightened in v0.9.5). We exclusively read public data, which needs no repo scope; `public_repo` was dropped because — despite its name — it grants *write* access to public repos and needlessly widened a leaked token's blast radius. Never `repo`, never `admin:*`.
- Token storage: **server-side opaque session** cookie (`secrets.token_urlsafe(32)`); the GitHub access token is **AES-GCM encrypted at rest** in the `sessions` row with a per-environment `SESSION_TOKEN_ENC_KEY`.
- Signed-in `/analyze` uses the user's GitHub token for ingestion, giving each user a dedicated 5000/hr rate-limit budget.

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ Every version listed here must correspond to a slice in [`PLAN.md`](./PLAN.md) w

---

## [0.9.5] — 2026-05-28

### Security
- **Full pre-launch security review — no high or critical findings.** Authorization (ownership checks on every mutation), session encryption, OAuth CSRF protection, SQL-injection safety, output escaping, and SSRF protection on user-supplied input were all verified sound.

### Changed
- **Tightened the GitHub sign-in permission to read-only.** Sign-in previously requested a scope that technically allowed writing to your public repositories; it now requests read-only access only, since Skill Issue exclusively reads public data. (Existing sessions are unaffected; the narrower permission applies on next sign-in.)
- **Added HTTP security headers** (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`) plus a report-only Content-Security-Policy as a baseline for hardening before public launch.

---

## [0.9.4] — 2026-05-28

### Changed
Expand Down
33 changes: 27 additions & 6 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@
| **v0.9.2** | Rate limiting (IP + user) on `/analyze` + `/narrative` | ✅ shipped |
| **v0.9.3** | Deletable `/me` history + back-nav loading fix + creator flair | ✅ shipped |
| **v0.9.4** | DB pool size env-tunable + real back-nav spinner fix | ✅ shipped |
| **v0.9.5** | `/security-review` pass + load test to 100 RPS | pending |
| **v0.9.6** | Privacy policy + terms (legal docs) | pending |
| **v0.9.5** | Security review + hardening (OAuth scope ↓ `read:user`, HTTP security headers) | ✅ shipped |
| **v0.9.6** | Load test to 100 RPS | pending |
| **v0.9.7** | Privacy policy + terms (legal docs) | pending |
| **v1.0.0** | Public launch | pending |

---
Expand Down Expand Up @@ -175,7 +176,7 @@
**Design spec:** [`docs/superpowers/specs/2026-05-16-v0.5.0-auth-persistence-design.md`](./docs/superpowers/specs/2026-05-16-v0.5.0-auth-persistence-design.md).

**Slice scope:**
- GitHub **OAuth App** (not GitHub App), server-side flow, scopes `read:user public_repo`. State token in a short-lived httpOnly cookie. Server-side opaque sessions in Postgres (no JWT).
- GitHub **OAuth App** (not GitHub App), server-side flow, scope `read:user` (v0.9.5 dropped `public_repo`). State token in a short-lived httpOnly cookie. Server-side opaque sessions in Postgres (no JWT).
- **Neon Postgres** schema (5 tables): `users`, `sessions`, `analyses`, `analysis_runs`, `narratives`. Cascade deletes from `users` clean everything up.
- **SQLAlchemy 2.0 async + asyncpg** against Neon's pooled host (port 6543, `statement_cache_size=0`). Direct host (port 5432) only for Alembic migrations.
- **Alembic** migrations, hand-edited, reversibility tested.
Expand Down Expand Up @@ -649,15 +650,35 @@ The narrative-mode CHECK constraint was a third drift in the same family — the

---

## v0.9.5 — `/security-review` pass + load test (deferred)
## v0.9.5 — Security review + hardening (shipped 2026-05-28)

**Goal:** Run `/security-review` against the codebase; resolve any high/critical findings. Load-test to 100 RPS sustained, verify error budget holds.
**Goal:** Full pre-launch security audit of the whole app; resolve any high/critical findings. (The load test was split out to v0.9.6 — it needs a deliberate target/cost/rate-limit-bypass design and is independently shippable.)

**Audit result:** No high or critical findings. Verified sound: authorization (every mutation ownership-checked via `_owned_analysis`, no IDOR), AES-GCM session-token encryption, OAuth `state` CSRF with constant-time compare, no SQL injection (SQLAlchemy constructs only), no XSS (no `dangerouslySetInnerHTML`; LLM narrative renders as escaped text), no SSRF (username regex-validated server-side; GitHub URLs built only from validated input + trusted API responses), server-only secrets.

**Fixes shipped (two Mediums):**
- **OAuth scope `read:user public_repo` → `read:user`.** `public_repo` is a *write* scope; reading public data needs none. Reduces a leaked stored token's blast radius. New logins only; existing sessions unaffected.
- **HTTP security headers** in `frontend/next.config.ts`: enforced `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`; plus a **report-only** Content-Security-Policy (logs violations without blocking — to be tuned against real reports before enforcing).

**Operator follow-ups (config, no code):** verify `COOKIE_SECURE=true` in prod; confirm `CORS_ALLOW_ORIGIN_REGEX` is scoped to our own origins (not `*.vercel.app`).

**Exit criteria:**
- [x] Whole-app security audit completed; findings severity-ranked.
- [x] All high/critical findings resolved (none found).
- [x] OAuth scope tightened + test; security headers added; `next build` clean.
- [x] Docs ritual + version bump to 0.9.5; tag + release.

---

## v0.9.6 — Load test to 100 RPS (deferred)

**Goal:** Load-test to 100 RPS sustained and verify the error budget holds. Needs a deliberate design: target (prod vs preview vs local), cost ceiling (Vercel Active-CPU pricing), and how to handle the v0.9.2 rate limits (a naive test from one IP just measures 429s — raise limits for the window, test `/health` + a warm-cached path, or use a bypass).

**Exit criteria:** TBD when the slice begins.

---

## v0.9.6 — Legal docs (deferred)
## v0.9.7 — Legal docs (deferred)

**Goal:** Privacy policy + terms in `docs/legal/`. Link from frontend footer.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Engineering insight first. AI flavor second. Scoring is deterministic and explai

## Status

Pre-alpha. Latest shipped release is **v0.9.4** (the database connection pool size is now tunable via environment variables without a redeploy, and the search spinner that could stick after pressing browser Back is genuinely fixed). v0.9.3 before it added deletable `/me` history with undo, a golden "creator" scorecard for the project's creator account, and a first (incomplete) attempt at the back-nav spinner fix. Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms); v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals), structlog JSON logging, on-voice 404, and a full axe a11y pass; v0.8.1 ships the nightly cron with bearer auth; v0.8.2 pairs it with the manual force-refresh button on `/me`; v0.8.3 hotfixes the empty-repo crash; v0.8.4 fixes the silent narrative misattribution; v0.8.5 closes the post-deploy-Sentry loop with a pre-merge CI gate; v0.8.6 closes v0.7.1's deferred share-page caching; v0.8.7 modernizes project config; v0.9.0 opens Beta hardening with bounded GH fan-out; v0.9.1 closes the /me N+1 + adds per-namespace Report cache versioning; v0.9.2 adds rate limiting (per-IP for anonymous, higher per-user caps for signed-in) on `/analyze` and `/narrative`; v0.9.3 adds deletable `/me` history with undo, attempts the back-nav search-spinner fix, and gilds the creator's scorecard. v0.9.4 makes the DB connection pool size env-tunable (defaults unchanged — RUM showed no pool exhaustion) and lands the real back-nav spinner fix (the v0.9.3 attempt addressed the wrong mechanism). **v0.9.5 — security review + load test** is next. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff.
Pre-alpha. Latest shipped release is **v0.9.5** (a full pre-launch security audit — no high/critical findings — that tightened the GitHub OAuth scope to read-only and added HTTP security headers). v0.9.4 before it made the DB connection pool size env-tunable and genuinely fixed the back-nav search spinner; v0.9.3 added deletable `/me` history with undo, a golden "creator" scorecard for the project's creator account, and a first (incomplete) attempt at the back-nav spinner fix. Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms); v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals), structlog JSON logging, on-voice 404, and a full axe a11y pass; v0.8.1 ships the nightly cron with bearer auth; v0.8.2 pairs it with the manual force-refresh button on `/me`; v0.8.3 hotfixes the empty-repo crash; v0.8.4 fixes the silent narrative misattribution; v0.8.5 closes the post-deploy-Sentry loop with a pre-merge CI gate; v0.8.6 closes v0.7.1's deferred share-page caching; v0.8.7 modernizes project config; v0.9.0 opens Beta hardening with bounded GH fan-out; v0.9.1 closes the /me N+1 + adds per-namespace Report cache versioning; v0.9.2 adds rate limiting (per-IP for anonymous, higher per-user caps for signed-in) on `/analyze` and `/narrative`; v0.9.3 adds deletable `/me` history with undo, attempts the back-nav search-spinner fix, and gilds the creator's scorecard. v0.9.4 makes the DB connection pool size env-tunable (defaults unchanged — RUM showed no pool exhaustion) and lands the real back-nav spinner fix (the v0.9.3 attempt addressed the wrong mechanism); v0.9.5 runs a full pre-launch security audit (no high/critical findings), tightens the OAuth scope to `read:user`, and adds HTTP security headers. **v0.9.6 — load test to 100 RPS** is next. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff.

---

Expand Down Expand Up @@ -76,7 +76,7 @@ cp .env.example .env # then edit .env and add your GITHUB_TOKEN and OPENA
uv run uvicorn app.main:app --reload --port 8000
```

Verify: `curl http://localhost:8000/health` → `{"status":"ok","version":"0.9.4","db":"up"|"down","cache":"up"|"down"|"unconfigured"}`. The `db` field reports DB reachability when `DATABASE_URL` is configured; the `cache` field reports Upstash reachability (`unconfigured` when `UPSTASH_REDIS_REST_URL` isn't set — perfectly fine for local dev, the in-process fallback covers it).
Verify: `curl http://localhost:8000/health` → `{"status":"ok","version":"0.9.5","db":"up"|"down","cache":"up"|"down"|"unconfigured"}`. The `db` field reports DB reachability when `DATABASE_URL` is configured; the `cache` field reports Upstash reachability (`unconfigured` when `UPSTASH_REDIS_REST_URL` isn't set — perfectly fine for local dev, the in-process fallback covers it).
Hit the analyzer: `curl http://localhost:8000/analyze/octocat`.

### Frontend (`:3000`)
Expand Down
12 changes: 10 additions & 2 deletions backend/app/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,18 @@ def verify_state_token(*, cookie: str | None, query: str) -> None:


def build_authorize_url(state: str) -> str:
"""Construct the GitHub authorize URL we 302 to."""
"""Construct the GitHub authorize URL we 302 to.

Scope is `read:user` only. We exclusively READ public GitHub data, which
needs no repo scope at all — an authenticated token reads public repos/
commits/contents regardless of scope, and `read:user` covers the profile.
`public_repo` was dropped in v0.9.5: despite its name it grants *write*
access to public repositories, so it needlessly widened the blast radius of
a leaked stored token. Never `repo`, never `admin:*`.
"""
params = {
"client_id": settings.github_oauth_client_id or "",
"scope": "read:user public_repo",
"scope": "read:user",
"state": state,
"redirect_uri": settings.oauth_redirect_url or "",
}
Expand Down
2 changes: 1 addition & 1 deletion backend/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from pydantic_settings import BaseSettings, SettingsConfigDict

VERSION = "0.9.4"
VERSION = "0.9.5"


class Settings(BaseSettings):
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "skill-issue-backend"
version = "0.9.4"
version = "0.9.5"
description = "Skill Issue backend — FastAPI service that ingests a GitHub profile and returns a deterministic engineering report."
readme = "README.md"
authors = [
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/auth/test_oauth.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import secrets
from urllib.parse import parse_qs, urlparse

import pytest

from app.auth.oauth import (
InvalidOAuthState,
build_authorize_url,
generate_state_token,
verify_state_token,
)


def test_authorize_url_requests_read_only_scope():
"""v0.9.5: scope must be `read:user` only — no write scope (`public_repo`),
never `repo`/`admin:*`. The app only reads public data."""
qs = parse_qs(urlparse(build_authorize_url("state123")).query)
assert qs["scope"] == ["read:user"]
assert "public_repo" not in qs["scope"][0]
assert "repo" not in qs["scope"][0].split()
assert qs["state"] == ["state123"]


def test_state_token_is_url_safe_high_entropy():
s = generate_state_token()
assert len(s) >= 32
Expand Down
2 changes: 1 addition & 1 deletion backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ In Vercel → **Settings** → **Environment Variables**, add (Production + Prev

> **DB pool ceiling.** The Neon compute exposes ~105 usable connections (`max_connections` 112 - 7 `superuser_reserved_connections` on the current ~0.25 CU compute). The app connects through the PgBouncer pooler (`statement_cache_size=0`), which multiplexes many client connections onto few server ones — so the ceiling is heavily buffered. If ever switched to a direct connection, keep `(DB_POOL_SIZE + DB_MAX_OVERFLOW) × peak_instances < 105`.

> **Security pre-launch checklist (v0.9.5).** The v0.9.5 audit found no high/critical issues, but confirm these *config* items before public launch: (1) `COOKIE_SECURE=true` is set in prod; (2) `CORS_ALLOW_ORIGIN_REGEX` is scoped to our own origins only (never a blanket `*.vercel.app`); (3) promote the report-only Content-Security-Policy in `frontend/next.config.ts` to enforcing once it's been tuned against real violation reports.

### 5. Run the initial Alembic migration

The DB schema lives in `backend/migrations/`. Pulling the prod `DATABASE_DIRECT_URL` locally (or pasting it once into a shell session — don't persist it) is the safe way to run migrations:
Expand Down
29 changes: 29 additions & 0 deletions docs/PROGRESS_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,35 @@ Format:

---

## 2026-05-28 — Claude (Opus 4.7) — v0.9.5 shipped (pre-launch security audit + hardening)

**Slice:** v0.9.5. Full pre-launch security audit of the whole app + two Medium hardening fixes. The load test originally bundled here was split to v0.9.6 (needs target/cost/rate-limit design); legal docs shifted to v0.9.7.

**Done:**
- **Whole-app security audit — no high/critical findings.** Reviewed: authz/IDOR, session crypto, OAuth CSRF, SQLi, XSS, SSRF, secrets, CORS, rate limiting, security headers. All core surfaces sound (see Decisions for specifics).
- **Fix #1 — OAuth scope `read:user public_repo` → `read:user`** (`app/auth/oauth.py`). Added `test_authorize_url_requests_read_only_scope`. Updated current-state docs (ARCHITECTURE/TECH_STACK/PLAN); left historical changelog/spec entries as-is.
- **Fix #2 — HTTP security headers** (`frontend/next.config.ts` `headers()`): enforced `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`; plus a **report-only** CSP (logs, doesn't block) as a tunable baseline.
- Docs ritual + version bump 0.9.5; DEPLOY security pre-launch checklist note.

**Decisions:**
- **Verified-clean surfaces:** every mutation ownership-checked via `_owned_analysis` (`id AND user_id` → 403; no IDOR); AES-GCM token encryption (fail-fast 32-byte key, random nonce); OAuth `state` CSRF constant-time; no raw SQL (SQLAlchemy constructs only; `text()` only for `SELECT 1`); no `dangerouslySetInnerHTML` (LLM narrative escaped by React); username regex-validated server-side so GitHub URLs never take a user-controlled host (no SSRF); `INTERNAL_PROXY_SECRET`/`REVALIDATE_SECRET` server-only + constant-time compared.
- **`public_repo` is a write scope** — a common misconception treated it as "read public." Reading public data needs no repo scope at all; dropping it shrinks a leaked token's blast radius with zero functional loss (new logins only).
- **CSP report-only, not enforcing** — a wrong CSP silently breaks PostHog/Sentry/Next and I can't browser-test enforcement headlessly. Report-only ships the signal safely; promote after tuning.
- **Load test split out (v0.9.6)** — our own v0.9.2 rate limits would make a naive 100 RPS test just measure 429s; it needs a deliberate target + cost design, and is independently shippable.

**Operator follow-ups (config, no code):** confirm `COOKIE_SECURE=true` in prod; ensure `CORS_ALLOW_ORIGIN_REGEX` is scoped to our origins (not `*.vercel.app`). Both noted in DEPLOY.md.

**Verified:**
- Backend `ruff` clean; `pytest tests/auth/test_oauth.py` 6 passed (incl. new scope test). Full suite to re-confirm pre-commit.
- Frontend `lint` + `tsc` clean; `next build` clean (headers config valid).
- Security headers verifiable via `curl -I` on prod after deploy.

**Blocked / open:** push/PR/CI/merge/tag pending (confirm-before-tag per the session norm).

**Next:** v0.9.6 — load test to 100 RPS (design the target + rate-limit handling first).

---

## 2026-05-28 — Claude (Opus 4.7) — v0.9.4 shipped (DB pool size env-tunable + real back-nav spinner fix)

**Slice:** v0.9.4. Two changes: the planned DB-pool work, plus a genuine fix for the back-nav search spinner that v0.9.3 only *appeared* to fix.
Expand Down
2 changes: 1 addition & 1 deletion docs/TECH_STACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@

| Tool | Role |
| --- | --- |
| **GitHub OAuth (App)** | Sign-in + higher API rate limits. Scopes: `read:user`, `public_repo`. Never `repo` or `admin:*`. |
| **GitHub OAuth (App)** | Sign-in + higher API rate limits. Scope: `read:user` only (v0.9.5 dropped the write-granting `public_repo`; reading public data needs no repo scope). Never `repo` or `admin:*`. |
| **Server-side opaque sessions** | Cookie value is `secrets.token_urlsafe(32)`; server looks the row up directly. Chosen over JWT in v0.5.0 to keep revocation cheap and access tokens server-side. |
| **AES-GCM at rest** | GitHub access tokens encrypted in the `sessions` table with `SESSION_TOKEN_ENC_KEY`. Fresh 12-byte nonce per row; key rotation invalidates every session by design. |

Expand Down
Loading
Loading