Skip to content

Commit 32e60f4

Browse files
feat: add ruff and pip-audit CI gates, fix style violations, and add … (#70)
* feat: add ruff and pip-audit CI gates, fix style violations, and add SECURITY.md * fix: address PR #70 review — duplicate exclusion check, ruff format CI, and test cleanup * fix: strengthen exception-leak static guard; lift safe_join to module import in export_api
1 parent 6952d6d commit 32e60f4

45 files changed

Lines changed: 1166 additions & 861 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,40 @@ jobs:
4343
assert client.get("/").status_code == 200
4444
PY
4545
46+
lint-and-audit:
47+
name: ruff + pip-audit (${{ matrix.os }})
48+
runs-on: ${{ matrix.os }}
49+
strategy:
50+
fail-fast: false
51+
matrix:
52+
os: [ubuntu-latest, windows-latest]
53+
permissions:
54+
contents: read
55+
steps:
56+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
57+
with:
58+
persist-credentials: false
59+
60+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
61+
with:
62+
python-version: "3.12"
63+
cache: pip
64+
cache-dependency-path: |
65+
requirements.txt
66+
requirements-dev.txt
67+
68+
- name: Install dev dependencies
69+
run: pip install -r requirements-dev.txt
70+
71+
- name: Ruff
72+
run: ruff check .
73+
74+
- name: Ruff format check
75+
run: ruff format --check .
76+
77+
- name: pip-audit
78+
run: pip-audit -r requirements.txt
79+
4680
pytest:
4781
name: pytest (${{ matrix.os }})
4882
runs-on: ${{ matrix.os }}

CONTRIBUTING.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Thanks for considering a patch. This repo is a small Flask app plus a hash-route
99
- **Python 3.12** (matches CI)
1010
- **Node 20+** (only if you change `static/js/` or run frontend unit tests)
1111

12-
CI runs **`pytest`**, **integration tests**, and **Vitest** on **ubuntu-latest** and **windows-latest** (Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only.
12+
CI runs **`ruff check`**, **`ruff format --check`**, **`pip-audit`**, **`pytest`**, **integration tests**, and **Vitest** on **ubuntu-latest** and **windows-latest** (Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only.
1313

1414
### Bootstrap (Windows PowerShell)
1515

@@ -58,6 +58,9 @@ When changing JSON response shapes, update the API reference stability column an
5858
### Python
5959

6060
```bash
61+
ruff check . # lint (E, F, W, I) — same gate as CI
62+
ruff format --check . # formatting gate; run `ruff format .` to fix
63+
pip-audit -r requirements.txt # production dependency audit (CI gate)
6164
pytest -q # full suite + coverage (see pyproject.toml)
6265
pytest tests/test_api_integration.py -v
6366
pytest tests/test_search.py -v
@@ -85,7 +88,8 @@ npm run test:coverage # optional
8588
| **Exception leakage** | `5xx` bodies are generic messages only. Log full tracebacks with `current_app.logger.exception(...)`. Never put `str(e)` or class names in HTTP JSON (issue #25). |
8689
| **Path safety** | Use `safe_join()` from `utils/session_path.py` for any path built from URL segments. |
8790
| **Imports** | stdlib → third-party → local, blank line between groups. |
88-
| **Line length** | ~100 characters; no enforced formatter yet. |
91+
| **Lint / format** | `ruff check .` and `ruff format --check .` (CI gates). Config in `pyproject.toml`; run `ruff format .` to apply formatting locally. |
92+
| **Line length** | 100 characters (`line-length` in `pyproject.toml`). |
8993

9094
## Tests required for common changes
9195

@@ -105,9 +109,10 @@ npm run test:coverage # optional
105109
- Branch names: `feat/<topic>`, `fix/<topic>`, `test/<topic>`, `chore/<topic>`, `docs/<topic>`.
106110
- One logical change per PR when possible.
107111
- PR checklist:
112+
- [ ] `ruff check .` and `ruff format --check .` green locally
108113
- [ ] `pytest -q` green locally
109114
- [ ] `npm test` green if JS changed
110-
- [ ] CI jobs green (`pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows; `mypy`, `prod-install-smoke` on Ubuntu)
115+
- [ ] CI jobs green (`lint-and-audit`, `pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows; `mypy`, `prod-install-smoke` on Ubuntu)
111116
- [ ] PR description includes a **Test plan** section
112117
- [ ] API changes update [`docs/api-reference.md`](docs/api-reference.md) if behavior or errors change
113118

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ REST endpoints for projects, sessions, search, and export are documented in **[`
2727

2828
JSON error responses include a machine-readable `"code"` (stable `UPPER_SNAKE_CASE`) and a human-readable `"error"` message. See the [error code catalog](docs/api-reference.md#error-code-catalog) for the full table.
2929

30+
- **Security policy** — see [`SECURITY.md`](SECURITY.md) for supported versions and how to report vulnerabilities privately
31+
3032
### CLI Export
3133
- Standalone script to export all sessions to Markdown with YAML frontmatter
3234
- Rich Markdown: token usage, tool calls, thinking blocks, model info, timestamps
@@ -155,7 +157,7 @@ npm ci && npm test # only if you changed static/js/
155157

156158
## Continuous integration
157159

158-
Every push and pull request runs **`pytest`**, **API integration tests**, and **vitest** on **Ubuntu** (Python 3.12, Node 20) via [`.github/workflows/ci.yml`](.github/workflows/ci.yml). A separate job verifies that `pip install -r requirements.txt` (production-only) is sufficient to import and boot the app.
160+
Every push and pull request runs **`ruff check`**, **`ruff format --check`**, **`pip-audit`**, **`pytest`**, **mypy**, **API integration tests**, and **vitest** on **Ubuntu and Windows** (Python 3.12, Node 20) via [`.github/workflows/ci.yml`](.github/workflows/ci.yml). A separate job verifies that `pip install -r requirements.txt` (production-only) is sufficient to import and boot the app.
159161

160162
## Exported Markdown Format
161163

SECURITY.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Security Policy
2+
3+
## Supported Versions
4+
5+
This project is pre-release. Security fixes are applied to the **latest `master` branch only** (currently `0.1.0.dev0`).
6+
7+
| Version | Supported |
8+
| -------------- | --------- |
9+
| latest `master` | Yes |
10+
| older commits | No |
11+
12+
## Reporting a Vulnerability
13+
14+
**Please do not open public GitHub issues for security vulnerabilities.**
15+
16+
**Primary path (always works):** Contact a [repository maintainer](https://github.com/cppalliance/claude-code-chat-browser/graphs/contributors) through a private channel you already use with the project (for example a direct message or private email). Include steps to reproduce, affected version/commit, and impact.
17+
18+
**GitHub Security Advisories (when enabled):** Once **Private vulnerability reporting** is turned on for this repository (Settings → Security → Private vulnerability reporting), external researchers may use the [private advisory form](https://github.com/cppalliance/claude-code-chat-browser/security/advisories/new). If that link returns 404 or the form is unavailable, use the primary path above — the advisory URL only works when the setting is enabled.
19+
20+
**Repository admins:** Enable private vulnerability reporting before merge if you want the advisory form to be the default reporter path; until then, maintainers should treat the primary path as authoritative.
21+
22+
## Response Timeline
23+
24+
| Stage | Target |
25+
| ----- | ------ |
26+
| Acknowledgment | Within **72 hours** of a valid report |
27+
| Initial assessment | Within **7 days** |
28+
| Fix for confirmed issues | Target **14 days** for issues affecting the default local-only deployment |
29+
30+
Timelines may extend for complex issues; we will keep reporters informed.
31+
32+
## Scope
33+
34+
### In scope
35+
36+
Security issues in this repository that affect users of the default local setup:
37+
38+
- **Path traversal** — session and export paths resolved via `safe_join` in `utils/session_path.py`
39+
- **Cross-site scripting (XSS)** — rendered session HTML in `static/js/` (mitigated by DOMPurify + SRI in `static/index.html`)
40+
- **Export integrity** — bulk zip and per-session export in `api/export_api.py` and `utils/export_engine.py`
41+
- **Local file boundaries** — read-only access to `~/.claude/projects/`; writes limited to export output and app state
42+
- **Debug-mode exposure** — Flask/Werkzeug debugger when `--debug` is combined with a non-loopback `--host` (blocked at startup in `app.py`)
43+
- **Information disclosure** — API error responses scrub internal exception details (see `api/error_codes.py`)
44+
45+
### Out of scope
46+
47+
- **Intentional network-facing deployment** — this tool is designed for local browsing on loopback; exposing it on untrusted networks is not a supported configuration
48+
- **Upstream Claude Code JSONL format bugs** — malformed or hostile data from Claude Code itself (we harden parsing but do not guarantee full isolation from arbitrary JSONL)
49+
- **Third-party CDN availability** — DOMPurify is loaded from cdnjs with SRI; CDN compromise is an infrastructure concern outside this repo
50+
51+
## Existing Controls (reference)
52+
53+
| Control | Location |
54+
| ------- | -------- |
55+
| Path guard (`safe_join`) | `utils/session_path.py` |
56+
| HTML sanitization (DOMPurify) | `static/js/shared/markdown.js`, `static/index.html` |
57+
| Error response scrubbing | `api/error_codes.py`, session card handling in `api/projects.py` |
58+
| Debug + non-loopback host guard | `app.py` (`validate_startup_cli`) |

api/export_api.py

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Export endpoints -- bulk zip download and single-session md/json."""
22

33
import io
4-
import json
54
import os
65
import zipfile
76
from datetime import datetime
@@ -12,20 +11,20 @@
1211
from api._flask_types import FlaskReturn, json_response
1312
from api.error_codes import ErrorCode, error_response
1413
from models.export import ExportStateDict
14+
from utils.exclusion_rules import is_session_excluded
15+
from utils.export_engine import EXPORT_ERRORS as _EXPORT_ERRORS, ZipSink, run_bulk_export
1516
from utils.export_state_store import (
1617
EXPORT_STATE_FILE,
1718
atomic_write_export_state,
1819
export_state_lock,
1920
load_export_state_from_disk,
2021
)
21-
from utils.session_path import get_claude_projects_dir, list_projects
22+
from utils.json_exporter import session_to_json
2223
from utils.jsonl_parser import parse_session
23-
from utils.exclusion_rules import is_session_excluded
24-
from utils.session_stats import compute_stats
2524
from utils.md_exporter import session_to_markdown
26-
from utils.json_exporter import session_to_json
25+
from utils.session_path import get_claude_projects_dir, list_projects, safe_join
26+
from utils.session_stats import compute_stats
2727
from utils.slugify import slugify
28-
from utils.export_engine import EXPORT_ERRORS as _EXPORT_ERRORS, ZipSink, run_bulk_export
2928

3029
export_bp = Blueprint("export", __name__)
3130

@@ -94,10 +93,7 @@ def bulk_export() -> FlaskReturn:
9493
since=since,
9594
)
9695

97-
base = (
98-
current_app.config.get("CLAUDE_PROJECTS_DIR")
99-
or get_claude_projects_dir()
100-
)
96+
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
10197
projects = list_projects(base)
10298
rules = current_app.config.get("EXCLUSION_RULES") or []
10399

@@ -109,9 +105,7 @@ def bulk_export() -> FlaskReturn:
109105
buf = io.BytesIO()
110106

111107
def _on_export_error(sid: str, exc: Exception) -> None:
112-
current_app.logger.warning(
113-
"Failed to export %s: %s", sid[:10], exc
114-
)
108+
current_app.logger.warning("Failed to export %s: %s", sid[:10], exc)
115109

116110
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
117111
result = run_bulk_export(
@@ -161,12 +155,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
161155

162156
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
163157
def export_session(project_name: str, session_id: str) -> FlaskReturn:
164-
from utils.session_path import safe_join
165-
166-
base = (
167-
current_app.config.get("CLAUDE_PROJECTS_DIR")
168-
or get_claude_projects_dir()
169-
)
158+
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
170159
try:
171160
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
172161
except ValueError:
@@ -183,9 +172,7 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
183172
try:
184173
session = parse_session(filepath)
185174
except _EXPORT_ERRORS:
186-
current_app.logger.exception(
187-
"Failed to parse session %s for export", session_id
188-
)
175+
current_app.logger.exception("Failed to parse session %s for export", session_id)
189176
return error_response(
190177
ErrorCode.PARSE_ERROR,
191178
"Failed to parse session",
@@ -203,9 +190,7 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
203190
try:
204191
stats = compute_stats(session)
205192
except _EXPORT_ERRORS:
206-
current_app.logger.exception(
207-
"Failed to compute stats for export %s", session_id
208-
)
193+
current_app.logger.exception("Failed to compute stats for export %s", session_id)
209194
return error_response(
210195
ErrorCode.INTERNAL_ERROR,
211196
"Failed to compute session stats",

api/projects.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from flask import Blueprint, current_app
44

5-
from api._flask_types import FlaskReturn, json_error, json_response
5+
from api._flask_types import FlaskReturn, json_response
66
from api.error_codes import ErrorCode, error_response
77
from models.project import ProjectSessionRowDict, SessionListItemDict
88
from models.session import SessionDict
9-
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
109
from utils.exclusion_rules import is_session_excluded
10+
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
1111

1212
projects_bp = Blueprint("projects", __name__)
1313

@@ -49,6 +49,7 @@ def get_projects() -> FlaskReturn:
4949
# so the landing page matches what the workspace page shows.
5050
# Uses quick_session_info() which peeks at files without full parsing.
5151
from utils.jsonl_parser import quick_session_info
52+
5253
for project in projects:
5354
sessions = list_sessions(project["path"])
5455
titled_count = 0
@@ -81,6 +82,7 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
8182
sessions = list_sessions(project_dir)
8283
# Add summary preview for each session
8384
from utils.jsonl_parser import parse_session
85+
8486
rules = current_app.config.get("EXCLUSION_RULES") or []
8587
result: list[ProjectSessionRowDict] = []
8688
for s in sessions:

api/search.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
"""Search endpoint. Brute-force substring match across all sessions."""
22

3-
import os
4-
53
from flask import Blueprint, current_app, request
64

75
from api._flask_types import FlaskReturn, json_response
86
from api.error_codes import ErrorCode, error_response
97
from models.search import SearchHitDict
10-
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
11-
from utils.jsonl_parser import parse_session
128
from utils.exclusion_rules import is_session_excluded
9+
from utils.jsonl_parser import parse_session
10+
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
1311

1412
search_bp = Blueprint("search", __name__)
1513

@@ -71,14 +69,16 @@ def search() -> FlaskReturn:
7169
end = min(len(text), idx + len(query) + 80)
7270
snippet = text[start:end]
7371

74-
results.append({
75-
"project": project["name"],
76-
"session_id": session["session_id"],
77-
"title": session["title"],
78-
"role": msg["role"],
79-
"timestamp": msg.get("timestamp"),
80-
"snippet": snippet,
81-
})
72+
results.append(
73+
{
74+
"project": project["name"],
75+
"session_id": session["session_id"],
76+
"title": session["title"],
77+
"role": msg["role"],
78+
"timestamp": msg.get("timestamp"),
79+
"snippet": snippet,
80+
}
81+
)
8282
if len(results) >= max_results:
8383
break
8484

api/sessions.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77

88
from api._flask_types import FlaskReturn, json_response
99
from api.error_codes import ErrorCode, error_response
10-
from utils.session_path import get_claude_projects_dir, safe_join
10+
from utils.exclusion_rules import is_session_excluded
1111
from utils.jsonl_parser import parse_session
12+
from utils.session_path import get_claude_projects_dir, safe_join
1213
from utils.session_stats import compute_stats
13-
from utils.exclusion_rules import is_session_excluded
1414

1515
sessions_bp = Blueprint("sessions", __name__)
1616

@@ -89,14 +89,6 @@ def get_session_stats(project_name: str, session_id: str) -> FlaskReturn:
8989
500,
9090
)
9191

92-
rules = current_app.config.get("EXCLUSION_RULES") or []
93-
if is_session_excluded(rules, session, project_name):
94-
return error_response(
95-
ErrorCode.SESSION_NOT_FOUND,
96-
"Session not found",
97-
404,
98-
)
99-
10092
try:
10193
stats = compute_stats(session)
10294
return json_response(stats)

app.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@
33
__version__ = "0.1.0.dev0"
44

55
import argparse
6-
import os
76
import sys
87

98
from flask import Flask
109

10+
from api.export_api import export_bp
1111
from api.projects import projects_bp
12-
from api.sessions import sessions_bp
1312
from api.search import search_bp
14-
from api.export_api import export_bp
15-
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules
13+
from api.sessions import sessions_bp
14+
from utils.exclusion_rules import load_rules, resolve_exclusion_rules_path
1615

1716

1817
def _normalize_bind_host(host: str) -> str:
@@ -100,15 +99,19 @@ def build_cli_parser() -> argparse.ArgumentParser:
10099
"--debug",
101100
action="store_true",
102101
default=False,
103-
help="Enable Flask/Werkzeug debug mode (never use with --host 0.0.0.0 on untrusted networks).",
102+
help=(
103+
"Enable Flask/Werkzeug debug mode "
104+
"(never use with --host 0.0.0.0 on untrusted networks)."
105+
),
104106
)
105107
parser.add_argument("--base-dir", default=None, help="Override Claude projects dir")
106108
parser.add_argument(
107-
"--exclude-rules", "-e",
109+
"--exclude-rules",
110+
"-e",
108111
default=None,
109112
metavar="PATH",
110113
help="Path to exclusion rules file (sensitive sessions are omitted). "
111-
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
114+
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
112115
)
113116
return parser
114117

0 commit comments

Comments
 (0)