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
26 changes: 18 additions & 8 deletions .claude/agents/security-auditor.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
---
name: security-auditor
description: Audits PostgreSQL database models and FastAPI endpoints for tenant isolation and missing tenant_id filters.
description: Audits PatchOwner's FastAPI endpoints, CSV parsing, and HTML report rendering for input handling and output escaping. Use before changes to web.py, report.py, or the report template ship.
tools: Read, Grep, Glob, Bash
---

You are a security auditor specializing in multi-tenant SaaS application security and Row-Level Security (RLS).
You are a security reviewer for a small local-first Python web app. PatchOwner has no database, no accounts,
and no multi-tenant surface today (the PostgreSQL sketch in `docs/design/db/` is not wired in). Review what
exists:

### Audit Responsibilities:
1. Inspect all SQLAlchemy models in `patchowner/db/models.py` to verify every tenant-owned table contains an indexed `tenant_id` foreign key.
2. Review FastAPI route handlers in `patchowner/web.py` to confirm that queries strictly scope results to the authenticated tenant.
3. Verify that action state updates (`/api/v1/notices/{id}/act`) enforce tenant boundaries and log auditable action histories.

Return a concise risk summary identifying any missing `tenant_id` checks or cross-tenant query vulnerabilities.
1. `patchowner/web.py`: the upload form (`/`), `/replay` (multipart CSV upload, `days`, `fallback`) and `/act`
(JSON). Check size limits, range checks, and that every error returns a plain-English message rather than
a stack trace.
2. `patchowner/inventory.py` and `patchowner/kev.py`: untrusted CSV and the downloaded KEV JSON. Check that
malformed input raises `InventoryError` or `FeedError`, never an unhandled exception.
3. `patchowner/report.py` and `patchowner/templates/report.html`: Jinja2 autoescape must stay on; the only
`|safe` is the client-data JSON, which must be written with `<`, `>` and `&` escaped so inventory text
cannot close the `<script>` tag.
4. `patchowner/state.py`: the state file path comes from the CLI, not from a request; confirm nothing in a
request can choose the path.
5. `vercel.json`: security headers for the static site (CSP, frame-ancestors, nosniff).

Return a short risk summary: what you checked, what is fine, and any concrete finding with file and line.
If a future change introduces a database, every tenant-owned table needs an indexed `tenant_id` and every
query must be scoped by it; flag any model or query that is not.
36 changes: 36 additions & 0 deletions .claude/hooks/lint_changed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""PostToolUse hook: lint and format the Python file Claude just edited.

Claude Code hands the tool call to hooks as JSON on stdin. The edited path is
tool_input.file_path. Non-Python files and files outside the repo are ignored.
"""

import json
import pathlib
import subprocess
import sys


def main() -> int:
try:
payload = json.load(sys.stdin)
except (ValueError, OSError):
return 0
path = (payload.get("tool_input") or {}).get("file_path", "")
if not path or not path.endswith(".py"):
return 0
file = pathlib.Path(path)
repo = pathlib.Path(__file__).resolve().parents[2]
if not file.exists() or repo not in file.resolve().parents:
return 0
for cmd in (["uv", "run", "ruff", "check", "--fix", str(file)], ["uv", "run", "ruff", "format", str(file)]):
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
if r.returncode != 0:
# Report the lint findings back to Claude rather than failing silently.
print((r.stdout + r.stderr).strip()[-1500:], file=sys.stderr)
return 2
return 0


if __name__ == "__main__":
sys.exit(main())
13 changes: 4 additions & 9 deletions .claude/hooks/test_gate.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import subprocess
import sys


def main():
try:
input_data = json.load(sys.stdin)
Expand All @@ -14,17 +15,11 @@ def main():
sys.exit(0)

# Execute test suite via uv run pytest
result = subprocess.run(
["uv", "run", "pytest"], capture_output=True, text=True, timeout=60
)
result = subprocess.run(["uv", "run", "pytest"], capture_output=True, text=True, timeout=60)

if result.returncode != 0:
# Extract last 1000 characters of test output for context
stderr_output = (
result.stderr[-1000:]
if result.stderr
else result.stdout[-1000:]
)
stderr_output = result.stderr[-1000:] if result.stderr else result.stdout[-1000:]
output = {
"decision": "block",
"reason": f"Tests are failing. Fix assertions before completing:\n{stderr_output}",
Expand All @@ -34,6 +29,6 @@ def main():

sys.exit(0)


if __name__ == "__main__":
main()

4 changes: 2 additions & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"hooks": [
{
"type": "command",
"command": "uv run ruff check --fix \"$CLAUDE_FILE_PATHS\" && uv run ruff format \"$CLAUDE_FILE_PATHS\"",
"timeout": 15
"command": "python3 .claude/hooks/lint_changed.py",
"timeout": 30
}
]
}
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
push:
branches: ["**"]
pull_request:

permissions:
contents: read

jobs:
test:
name: lint and test (Python ${{ matrix.python }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python }}
enable-cache: true

- name: Install
run: uv sync --frozen

- name: Lint and format check
run: |
uv run ruff check .
uv run ruff format --check .

- name: Test
# The four tests that replay the real KEV catalog skip here: CI does not download the feed,
# so results do not depend on CISA's servers or on the catalog changing under us.
run: uv run pytest -q

- name: Smoke test the CLI entry point
run: uv run patchowner --help
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.14
3.12
89 changes: 63 additions & 26 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,63 @@
# PatchOwner Operating Contract &amp; Context Router

## Tech Stack
- Python 3.12, `uv` package manager, FastAPI REST microservices
- PostgreSQL with SQLAlchemy ORM &amp; Alembic migrations
- Multi-tenant architecture with Row-Level Security (`tenant_id` foreign keys)
- Carnegie Mellon SSVC 2.0 decision engine &amp; CISA KEV continuous sync

## Core Commands
- Environment Setup: `uv sync`
- Dev Server: `uv run uvicorn patchowner.web:app --reload`
- Test Suite: `uv run pytest`
- Lint &amp; Format: `uv run ruff check --fix && uv run ruff format`
- Type Check: `uv run mypy patchowner`
- DB Migrations: `uv run alembic upgrade head`

## Non-Negotiable Constraints
1. **Tenant Isolation**: Every SQL model and query must strictly scope to `tenant_id`
2. **SSVC Reason Integrity**: Preserve Carnegie Mellon SSVC 2.0 tree outcomes and 1-sentence plain-English reasons
3. **Plan First**: Draft implementation blueprints before editing core engines (`engine.py`, `web.py`, `db/`)
4. **Evidence-Based Done**: Verify all code changes via `uv run pytest` before turn completion

## Pointers & Extension Routing
- DB Isolation Rules: `.claude/rules/db.md` (lazy-loaded for `patchowner/db/**/*.py`)
- SSVC Tree Rules: `.claude/rules/ssvc.md` (lazy-loaded for `patchowner/engine.py`, `ssvc.py`)
- Security Auditor Subagent: `.claude/agents/security-auditor.md`
# PatchOwner: operating contract for Claude

PatchOwner is a local proof of concept. It replays the CISA Known Exploited Vulnerabilities (KEV) catalog
against an inventory CSV and writes one HTML report that says which notices would have gone to whom, which
stayed quiet, and why. The decision is the SEI/CERT SSVC 2.0 deployer tree. Read `README.md` first; it is
kept true to the code and the site.

## What actually exists

- Python 3.12 (pinned in `.python-version`; 3.13 also works), `uv` for everything.
- Pure-Python engine: `kev.py` (feed download and cache), `inventory.py` (CSV), `matching.py` (rapidfuzz),
`ssvc.py` (decision points, policy CSV), `decide.py` (routing, suppression, budget), `health.py`,
`state.py` (what people did, a JSON file), `report.py` + `templates/report.html` (Jinja2), `engine.py`.
- Two entry points: `patchowner replay` (CLI, writes `out/report.html`) and `patchowner serve`
(FastAPI: `/` upload form, `/replay`, `/act`). Local only. No accounts, no database, no email, no tickets.
- `site/index.html` is a **build artifact**: the rendered report for `examples/inventory.csv`, served at
https://patchowner.com by Vercel on every push to `main`. Rebuild it; do not hand-edit it
(`site/README.md` has the command).
- `docs/design/db/` is a schema sketch for a possible hosted multi-tenant version. It is not wired in and its
dependencies are not installed. Do not import it or describe it as part of the product.

## Commands

```
uv sync # environment
uv run patchowner replay examples/inventory.csv # writes out/report.html
uv run patchowner serve # http://127.0.0.1:8000
uv run pytest # the test suite; 4 tests skip until data/kev.json is cached
uv run ruff check --fix && uv run ruff format # lint and format (config in pyproject.toml)
```

## Non-negotiable constraints

1. **Say only what is true.** Every claim in `README.md`, the About tab, and the landing copy must be
checkable against the code or the data. Facts (CISA, the inventory) and estimates (matching, automatable)
stay labeled separately. An estimate never lowers urgency on its own. No notice ever claims a version is
affected: KEV carries no version data.
2. **Keep the disclaimers.** The sitewide notice, the banner, and the inline caution on Defer / Plan update
exist because every KEV entry is actively exploited and CISA's guidance is to patch now. Do not soften,
move, or hide them.
3. **SSVC integrity.** Decision-point vocabulary is the SEI/CERT one and is fixed. The default policy CSV is
the SEI example tree, unchanged. Every notice keeps its one-sentence plain-English reason and its SSVC
vector under "For the auditor".
4. **Plan first** before editing `decide.py`, `ssvc.py`, `engine.py`, `web.py`, or the report template:
write down the intended behaviour change and which tests prove it, then edit.
5. **Evidence-based done.** `uv run pytest` and `uv run ruff check` pass before a turn ends. If the change
touches the report, regenerate `site/index.html` from the template (needs network for the KEV feed) or say
plainly that the live site still needs a rebuild.
6. **Nothing leaves the machine.** No telemetry, no outbound calls except the KEV download. The static site
stores button state in the visitor's browser only.

## Planned, not built

Jira/ServiceNow, email and Slack delivery, overdue tracking, multi-tenant MSP views, KEV diffing, scanner
integrations, any AI-generated analysis, and the PostgreSQL schema in `docs/design/db/`. These wait for a
paying pilot. Describe them as future work, never as features.

## Pointers

- Hooks: `.claude/hooks/lint_changed.py` (ruff on edited Python files), `.claude/hooks/test_gate.py`
(pytest must pass before a turn ends).
- Subagent: `.claude/agents/security-auditor.md` reviews the FastAPI surface and the report template for
input handling and output escaping.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# PatchOwner

[![CI](https://github.com/e-allora/patchowner/actions/workflows/ci.yml/badge.svg)](https://github.com/e-allora/patchowner/actions/workflows/ci.yml)

**Only verified, relevant, actionable vulnerability notices, routed to the person who can act.**

Upload a list of the technology you run. PatchOwner replays the CISA Known Exploited Vulnerabilities (KEV)
Expand Down Expand Up @@ -37,12 +39,14 @@ When the policy answers Defer or Plan update, the notice carries the caution inl
uv sync
uv run patchowner replay examples/inventory.csv # writes out/report.html, open it in a browser
uv run patchowner serve # http://127.0.0.1:8000, upload a CSV
uv run pytest # 86 tests, including the five routing scenarios from the design doc
uv run pytest # 101 tests, including the five routing scenarios from the design doc
uv run patchowner replay examples/inventory.csv --policy my_policy.csv # your own risk appetite
```

The KEV feed is downloaded once to `data/kev.json`. Add `--refresh` to re-download.
Needs Python 3.12 or newer and [uv](https://docs.astral.sh/uv/).
The KEV feed is downloaded once to `data/kev.json`. Add `--refresh` to re-download. If the download fails,
the command says so in one sentence and exits 3; nothing else is touched. Four tests replay the real catalog
and skip until that file exists.
Needs [uv](https://docs.astral.sh/uv/) and Python 3.12 or 3.13 (pinned to 3.12 in `.python-version`; CI runs both).

## What is in the report

Expand Down Expand Up @@ -146,7 +150,9 @@ patchowner/report.py HTML rendering (templates/report.html), share text
patchowner/engine.py one call that runs the replay
patchowner/cli.py `patchowner replay` and `patchowner serve`
patchowner/web.py upload form and the /act endpoint
site/ the static copy served at patchowner.com; a build artifact, see site/README.md
docs/ the SSVC v2 paper and screenshots
docs/design/db/ schema sketch for a possible hosted version; not wired in, not a dependency
```

## Credits
Expand Down
23 changes: 23 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Security

PatchOwner is a local proof of concept. It reads a CSV you give it, downloads the public CISA KEV catalog,
and writes one HTML file. It does not send email, open tickets, contact anyone, or phone home. The web demo
(`patchowner serve`) binds to `127.0.0.1` by default and keeps what people did about a notice in a JSON file
on the machine running it. The static copy at https://patchowner.com has no server behind it: button clicks
stay in your browser.

## What this tool is not

It is not security advice. Every vulnerability it shows is on the CISA KEV catalog, which means it is being
exploited in the wild, and CISA's guidance is to patch immediately. The urgency labels come from an editable
policy table and are for testing the prioritization logic. See the About tab of any report.

## Reporting a problem

If you find a security problem in PatchOwner itself (for example, a way for an uploaded CSV to run script in
the report, or a path the server should not write to), please
[open an issue](https://github.com/e-allora/patchowner/issues). If the details are sensitive, say so in the
issue without the specifics and we will arrange a private channel.

Please do not report vulnerabilities in the products that appear in a report here. Those belong to the
vendor named on the notice and to CISA.
15 changes: 15 additions & 0 deletions docs/design/db/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Database schema sketch (not wired in)

These two files are a **design sketch** for a possible future multi-tenant, hosted version of PatchOwner:
SQLAlchemy models for tenants, users, assets, advisories, policies, notices, and an action history, plus an
async session factory for PostgreSQL.

**Nothing in the running product uses them.** PatchOwner today is a local proof of concept: it reads a CSV,
replays the CISA KEV catalog, and writes one HTML file. What people did about a notice lives in a small JSON
file (`out/state.json`). There is no database, no accounts, and no hosted service. SQLAlchemy and asyncpg are
not project dependencies, so these files do not import in the project environment and are excluded from lint
and tests.

They are kept here so the direction is visible, not hidden. If and when a paying pilot asks for a hosted
multi-tenant version, this is the starting point, and the first non-negotiable rule for that work is that every
tenant-owned table carries an indexed `tenant_id` and every query is scoped by it.
File renamed without changes.
File renamed without changes.
25 changes: 21 additions & 4 deletions patchowner/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Command line: `patchowner replay inventory.csv` and `patchowner serve`."""

from __future__ import annotations

import argparse
Expand All @@ -7,6 +8,7 @@

from .engine import run_replay
from .inventory import InventoryError
from .kev import FeedError
from .report import BANNER
from .ssvc import Policy, PolicyError
from .state import StateStore
Expand All @@ -16,23 +18,38 @@ def _replay(args: argparse.Namespace) -> int:
path = Path(args.inventory)
try:
policy = Policy.load(args.policy) if args.policy else None
r = run_replay(path.read_text(), inventory_name=path.name, days=args.days, fallback_email=args.fallback,
budget=args.budget, refresh=args.refresh, policy=policy, state=StateStore(args.state))
r = run_replay(
path.read_text(),
inventory_name=path.name,
days=args.days,
fallback_email=args.fallback,
budget=args.budget,
refresh=args.refresh,
policy=policy,
state=StateStore(args.state),
)
except InventoryError as e:
print(f"Inventory problem: {e}", file=sys.stderr)
return 2
except PolicyError as e:
print(f"Policy problem: {e}", file=sys.stderr)
return 2
except FeedError as e:
print(f"Catalog problem: {e}", file=sys.stderr)
return 3
s = r.summary
print(f"KEV catalog {s.catalog_version}: {s.advisories_in_window} advisories in the last {s.days} days; policy: {s.policy_name}")
print(f"{s.relevant_advisories} touched your {s.assets} assets; {s.sent} notices, {s.suppressed} suppressed, {s.unowned} without an owner")
print(
f"{s.relevant_advisories} touched your {s.assets} assets; {s.sent} notices, {s.suppressed} suppressed, {s.unowned} without an owner"
)
for u, n in s.by_urgency.items():
print(f" {u:<12}{n}")
print("state: " + ", ".join(f"{k} {v}" for k, v in s.by_state.items()))
for d in r.decisions:
if d.sent:
print(f"- [{d.urgency}] {d.match.advisory.cve_id} -> {d.match.asset.asset} -> {d.recipient_email} ({d.match.tier}; {d.vector or 'no path'})")
print(
f"- [{d.urgency}] {d.match.advisory.cve_id} -> {d.match.asset.asset} -> {d.recipient_email} ({d.match.tier}; {d.vector or 'no path'})"
)
for w in s.warnings:
print(f"! {w}")
for e, n in s.over_budget.items():
Expand Down
Loading
Loading