From a39291ad1485c8a4320e8c2cb4ff219232e92992 Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Tue, 21 Jul 2026 21:07:17 +0700 Subject: [PATCH 1/9] Add initial service: fetch, cache, serve AVE records (#1) Bakes in the resilience pattern (stale-cache-on-refresh-failure, no crash-loop on startup), rate limiting, and security headers from the start rather than as follow-up patches, per the reconciled context brief. --- Dockerfile | 8 +++ constants.py | 13 +++++ main.py | 142 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 ++ 4 files changed, 167 insertions(+) create mode 100644 Dockerfile create mode 100644 constants.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e611a99 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +ENV PORT=8080 +EXPOSE 8080 +CMD exec uvicorn main:app --host 0.0.0.0 --port ${PORT} diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..ea26e48 --- /dev/null +++ b/constants.py @@ -0,0 +1,13 @@ +SERVICE_NAME = "AVE Reference API" +STANDARD_URL = "https://aveproject.org" +REPO_URL = "https://github.com/aveproject/ave" + +MSG_RECORD_NOT_FOUND = "No record found for ave_id {ave_id}" +MSG_MITIGATION_NOT_FOUND = "No mitigation object found for ave_id {ave_id}" +MSG_INITIAL_FETCH_FAILED = ( + "Initial cache fetch failed, starting with an empty cache, " + "will retry on first request: {error}" +) +MSG_REFRESH_FAILED = ( + "Cache refresh failed, continuing to serve the last known-good cache: {error}" +) diff --git a/main.py b/main.py new file mode 100644 index 0000000..a4b8495 --- /dev/null +++ b/main.py @@ -0,0 +1,142 @@ +import logging +import time + +import httpx +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.middleware.cors import CORSMiddleware +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +from constants import ( + MSG_INITIAL_FETCH_FAILED, + MSG_MITIGATION_NOT_FOUND, + MSG_RECORD_NOT_FOUND, + MSG_REFRESH_FAILED, + REPO_URL, + SERVICE_NAME, + STANDARD_URL, +) + +logger = logging.getLogger("ave-api") +logging.basicConfig(level=logging.INFO) + +SOURCE_URL = ( + "https://raw.githubusercontent.com/aveproject/ave/main/" + "dist/ave-records-latest.json" +) +REFRESH_INTERVAL_SECONDS = 15 * 60 + +_cache: dict = {"records": [], "fetched_at": 0.0} + +limiter = Limiter(key_func=get_remote_address) + +app = FastAPI( + title=SERVICE_NAME, + description="A read-only reference implementation of the AVE standard's record lookup API.", +) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # public, read-only data, no reason to restrict + allow_methods=["GET"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + +async def _refresh_cache() -> None: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(SOURCE_URL) + response.raise_for_status() + records = response.json() + _cache["records"] = records + _cache["fetched_at"] = time.time() + + +async def _ensure_fresh() -> None: + if time.time() - _cache["fetched_at"] > REFRESH_INTERVAL_SECONDS: + try: + await _refresh_cache() + except Exception as e: + if not _cache["records"]: + # Never had a successful fetch at all; nothing to fall + # back to, this has to surface as a real failure. + raise + logger.warning(MSG_REFRESH_FAILED.format(error=e)) + + +@app.on_event("startup") +async def startup() -> None: + try: + await _refresh_cache() + except Exception as e: + logger.error(MSG_INITIAL_FETCH_FAILED.format(error=e)) + + +@app.get("/") +async def index() -> dict: + return { + "name": SERVICE_NAME, + "standard": STANDARD_URL, + "repo": REPO_URL, + "record_count": len(_cache["records"]), + "cache_age_seconds": round(time.time() - _cache["fetched_at"]), + "docs": "/docs", + } + + +@app.get("/records") +@limiter.limit("60/minute") +async def list_records(request: Request) -> list[dict]: + await _ensure_fresh() + return _cache["records"] + + +@app.get("/records/{ave_id}") +@limiter.limit("60/minute") +async def get_record(request: Request, ave_id: str) -> dict: + await _ensure_fresh() + for record in _cache["records"]: + if record.get("ave_id") == ave_id: + return record + raise HTTPException(status_code=404, detail=MSG_RECORD_NOT_FOUND.format(ave_id=ave_id)) + + +@app.get("/records/{ave_id}/mitigation") +@limiter.limit("60/minute") +async def get_mitigation(request: Request, ave_id: str) -> dict: + await _ensure_fresh() + for record in _cache["records"]: + if record.get("ave_id") == ave_id: + mitigation = record.get("mitigation") + if mitigation is None: + raise HTTPException( + status_code=404, + detail=MSG_MITIGATION_NOT_FOUND.format(ave_id=ave_id), + ) + return mitigation + raise HTTPException(status_code=404, detail=MSG_RECORD_NOT_FOUND.format(ave_id=ave_id)) + + +@app.get("/search") +@limiter.limit("60/minute") +async def search(request: Request, q: str = Query(..., min_length=1)) -> list[dict]: + await _ensure_fresh() + needle = q.lower() + fields = ("title", "description", "attack_class", "behavioral_fingerprint") + return [ + record + for record in _cache["records"] + if any(needle in str(record.get(field, "")).lower() for field in fields) + ] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc71452 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +httpx==0.27.2 +slowapi==0.1.9 From 91d708a66288a35e309b44c5c09ca51adfecaf8f Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Tue, 21 Jul 2026 21:29:03 +0700 Subject: [PATCH 2/9] Add CONTEXT.md, LANGUAGE.md, ARCHITECTURE.md, CLAUDE.md (#2) One coordinated documentation pass since these four reference each other: framing and scope (CONTEXT.md), vocabulary (LANGUAGE.md), structure and design decisions (ARCHITECTURE.md), and session rules (CLAUDE.md). --- .gitignore | 2 ++ ARCHITECTURE.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 57 +++++++++++++++++++++++++++++++++++++++++++++ CONTEXT.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ LANGUAGE.md | 20 ++++++++++++++++ 5 files changed, 203 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 CONTEXT.md create mode 100644 LANGUAGE.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c8e1b84 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,62 @@ +# ARCHITECTURE.md - aveproject/ave-api + +## The whole thing, in one diagram + +``` +aveproject/ave (source of truth) + | + | raw.githubusercontent.com fetch, on startup + every 15 min + v +in-memory cache (a Python dict, nothing more) + | + | four read-only routes + v +GET / service info, including cache_age_seconds +GET /records full list +GET /records/{ave_id} one record +GET /records/{ave_id}/mitigation one record's neutral mitigation object only +GET /search?q= substring match across title, description, attack_class, + behavioral_fingerprint +``` + +## Design decisions worth knowing before changing anything + +**No database.** At the corpus's current size, a Python dict in memory is +faster, simpler, and has fewer failure modes than any database would +introduce. Revisit only if the corpus grows to a size where startup fetch +time or memory footprint becomes a real problem, not before. + +**Lazy refresh, not a background scheduler.** `_ensure_fresh()` only +re-fetches when a request arrives and the cache is stale. No cron job, no +background thread, nothing that can silently die without anyone noticing a +missing heartbeat. + +**A refresh failure serves stale data instead of failing the request.** +See `main.py`'s `_ensure_fresh()` for the actual implementation; this is +stated here because it's a load-bearing design decision, not just a bug +fix. Only a service that has never had a single successful fetch fails +hard. + +**The `/mitigation` endpoint can only ever return the neutral `mitigation` +object.** This is structural, not a convention someone has to remember: the +code has no path to return anything else. The `mitigation` object's own +definition, and the boundary between it and any concrete, product-specific +control, is defined in `aveproject/ave`'s own schema, not in any single +implementation's docs; that boundary belongs to the standard, and this +service simply respects it. + +**Rate limiting exists because the hosting context has real, finite +headroom**, not as defensive-by-default paranoia. See `CHANGELOG.md` for +when and why this was added. + +## ADR: hosting platform + +Considered Vercel (rejected: serverless-only Python, no persistent +in-memory state, incompatible with this service's cache design). Chose +Render as primary (zero card on file, real cost is a slower cold start +after idle) over Cloud Run (technically better fit for the persistent- +container model, but requires a card on file even though usage should +stay within the free tier). See `README.md` deployment section for the +current live choice; check there before assuming this ADR reflects where +it's actually running today, ADRs record reasoning at decision time, they +don't self-update. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ac6d16f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md - session rules for aveproject/ave-api + +Read `CONTEXT.md` first if this is a fresh session; it has the full +framing. This file is the short, mechanical version for quick reference +mid-session. + +## Hard rules + +- No write endpoints. Ever. If a task description implies one, stop and + flag it rather than build it; this repo's entire identity is read-only. +- Never check in a copy of AVE record data. The cache is populated at + runtime from `aveproject/ave`; nothing record-shaped belongs committed + to this repo's own files. +- `/records/{ave_id}/mitigation` returns only the neutral `mitigation` + object. Do not extend it to return anything product-specific, from this + project's tools or any other implementation's. +- No em dashes, anywhere, including code comments and commit messages. +- No auth today, and don't add it speculatively. If real abuse is + observed, revisit; until then, the existing rate limiting is the + intended level of protection. + +## Resilience rules + +- Every network call this service makes (currently: one, the fetch to + `aveproject/ave`) must handle failure without taking down unrelated + requests. A dependency being briefly unreachable is a routine event, not + an incident; the cache-serves-stale-on-refresh-failure pattern in + `_ensure_fresh()` is the model to follow for any future external call + this service adds. +- Never let a startup-time failure crash-loop the container. Log and start + degraded (empty cache, will retry) rather than exit non-zero on a + transient boot-time network issue. +- If a new endpoint is added, it needs the same `@limiter.limit(...)` + decorator every existing route has. A route added without it is a gap in + the rate-limiting coverage, not an oversight to catch later. +- Timeouts on every outbound call, no exceptions. The existing fetch uses + `timeout=10`; any new outbound call needs an explicit timeout of its + own, never the client library's default. + +## Security rules + +- No em dashes, including in error messages returned to callers; a stray + one in a 404 detail message is still a house-style violation. +- Never log full request bodies or headers at INFO level or above; this + service has no secrets today, but logging discipline should not depend + on that staying true forever. +- CORS stays wide open (`allow_origins=["*"]`) deliberately; this is + public, read-only data with no reason to restrict origins. Don't narrow + it without a real reason, and don't widen anything else without one + either. + +## Before opening a PR + +- Run the validation commands in `README.md` against a local instance. +- Confirm CodeQL and Scorecard checks pass in CI. +- Check `ARCHITECTURE.md`'s ADR section before changing the hosting setup; + know what was already considered and rejected before re-proposing it. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..2126aad --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,62 @@ +# CONTEXT.md - aveproject/ave-api + +Context for anyone working on this repo, contributors and Claude Code +sessions alike. This repo is public; nothing below assumes the reader has +any prior history with this project. + +## What this is + +A read-only reference API for AVE (Agentic Vulnerability Enumeration). +Four endpoints: list records, get one record, get one record's neutral +mitigation object, search. No auth, no write path, ever. + +## What this is not + +Not the standard. AVE itself, its records and schema, lives in +[aveproject/ave](https://github.com/aveproject/ave); this repo has no +authority over that content and holds no copy of it, checked in, ever. +Not the only reference implementation, either, and doesn't need to be: +`GOVERNANCE.md` states plainly that anyone may run their own instance from +this source, and nothing about AVE depends on this specific deployment +staying available. + +Not a threat-intelligence product. If a feature request would turn this +into scanning, detection, scoring, or anything beyond looking up records +that already exist, it belongs in a different, separate project, not an +addition here. Keep this boring on purpose. + +## Data flow, one direction + +`aveproject/ave` publishes `dist/ave-records-latest.json`. This service +fetches it, caches it in memory, and serves it back out through four thin +endpoints. Nothing here originates data, transforms it meaningfully, or +stores a durable copy anywhere. If a task description involves writing new +record content, generating new fields, or anything that isn't "fetch, +cache, serve," stop and check whether the task actually belongs in +`aveproject/ave` instead. + +## Framing discipline + +Never describe this as "the AVE API," definite article, implying the only +one. "A reference API for AVE" or "the AVE reference implementation" is +correct. + +Never let this repo's own docs, README, or code comments describe a +third-party product or service, however closely associated with this +project's maintainers, as though it were part of the AVE standard or a +required dependency of this service. This service depends on +`aveproject/ave` alone. If it's ever worth mentioning that some other tool +happens to interoperate with AVE, that belongs in `README.md`'s own +clearly-labeled list of related projects, explained plainly enough for a +first-time reader, not referenced here as though the name alone were +self-evident. Anyone reading this file may be encountering this project +for the first time; nothing here should assume they already know its +history. + +## How to work on this repo + +See `CLAUDE.md` for session rules, including the resilience and security +rules. See `ARCHITECTURE.md` for how the service is actually structured. +See `GOVERNANCE.md` for decision process. See `SECURITY.md` before +touching anything auth- or network-adjacent, even though there's no auth +today; the file explains why and what would change that. diff --git a/LANGUAGE.md b/LANGUAGE.md new file mode 100644 index 0000000..96d1f27 --- /dev/null +++ b/LANGUAGE.md @@ -0,0 +1,20 @@ +# LANGUAGE.md - aveproject/ave-api + +Small vocabulary, kept small on purpose; this is a simple service and +doesn't need an elaborate style guide. + +- **"record," never "entry."** Matches `aveproject/ave`'s own terminology. + An AVE record is not a database entry or a row; it's a defined, + versioned unit of the standard. +- **"cache," never "database."** This service has no database. Calling + the in-memory dict a "database" anywhere, in code comments, docs, or + commit messages, misdescribes the architecture and invites someone to + eventually add one that isn't needed. +- **"implementation," not "consumer," when referring to a tool built + against AVE.** Matches `aveproject/ave`'s own `CONTEXT.md`. +- **"a reference API," not "the AVE API."** See `CONTEXT.md`'s framing + discipline section; this is the same principle applied as a mechanical + writing rule. +- No em dashes, anywhere, in any file, including code comments and commit + messages. Matches the convention already enforced across every AVE + project repo. From 2686334947d9738c3f71283e826833ad6d42f21b Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Tue, 21 Jul 2026 21:42:33 +0700 Subject: [PATCH 3/9] Complete repo: governance docs, changelog, skills, pre-commit, CI security tooling (#4) * Add community and governance files, flesh out README SECURITY.md, CONTRIBUTING.md, GOVERNANCE.md, CODE_OF_CONDUCT.md (Contributor Covenant v2.1, unmodified) didn't exist yet, built fresh rather than patched. README's deployment section names Render, matching ARCHITECTURE.md's ADR, rather than the stale Cloud Run reference in an earlier draft of this brief. * Add CHANGELOG.md Framed as a single [1.0.0] initial release rather than an Unreleased section building on a prior version, since this synthesizes the service's actual first shipped state rather than patching an existing changelog. * Add docker-compose.yml for local development * Add six custom skills under .claude/skills/ research, grill-with-doc, to-spec, to-tickets, implement, code-review, following a research -> interrogate -> spec -> tickets -> implement -> review shape built for this repo's own context. * Add .pre-commit-config.yaml trailing-whitespace/end-of-file-fixer/check-yaml/check-json/large-files, ruff + ruff-format, and two local hooks: no em dashes anywhere, and no stray mentions of one specific vendor's product name. Ran --all-files and fixed everything it flagged (ruff-format wrapped two lines in main.py) before committing. * Add CodeQL, Dependabot, and OpenSSF Scorecard CI Three Dependabot ecosystems (pip, docker, github-actions), not just Python, since the Dockerfile's base image and this repo's own Actions dependencies need patching too. Scorecard publishes results publicly, which is what README.md's badge (added in an earlier commit) links to. --- .claude/skills/code-review/SKILL.md | 22 +++++++ .claude/skills/grill-with-doc/SKILL.md | 22 +++++++ .claude/skills/implement/SKILL.md | 16 +++++ .claude/skills/research/SKILL.md | 17 ++++++ .claude/skills/to-spec/SKILL.md | 12 ++++ .claude/skills/to-tickets/SKILL.md | 13 ++++ .github/dependabot.yml | 17 ++++++ .github/workflows/codeql.yml | 40 +++++++++++++ .github/workflows/scorecard.yml | 38 ++++++++++++ .pre-commit-config.yaml | 30 ++++++++++ CHANGELOG.md | 38 ++++++++++++ CODE_OF_CONDUCT.md | 83 ++++++++++++++++++++++++++ CONTRIBUTING.md | 24 ++++++++ GOVERNANCE.md | 29 +++++++++ README.md | 63 ++++++++++++++++++- SECURITY.md | 29 +++++++++ docker-compose.yml | 8 +++ main.py | 8 ++- 18 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/code-review/SKILL.md create mode 100644 .claude/skills/grill-with-doc/SKILL.md create mode 100644 .claude/skills/implement/SKILL.md create mode 100644 .claude/skills/research/SKILL.md create mode 100644 .claude/skills/to-spec/SKILL.md create mode 100644 .claude/skills/to-tickets/SKILL.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 GOVERNANCE.md create mode 100644 SECURITY.md create mode 100644 docker-compose.yml diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md new file mode 100644 index 0000000..eec14e7 --- /dev/null +++ b/.claude/skills/code-review/SKILL.md @@ -0,0 +1,22 @@ +--- +name: code-review +description: Review a change against this repo's actual standards before it merges. Use as the last step, after implement. +--- + +Check, explicitly, not as a vague pass/fail impression: + +- Every hard rule in `CLAUDE.md`, re-checked against the actual diff, not + just the original plan; implementations sometimes drift from what + grill-with-doc validated. +- No em dashes anywhere in the diff, including comments and any new + documentation. +- No stray reference to a specific vendor's product name in new code or + docs, unless it's naming the actual, established reference + implementation, and even then only in a context that explains what it + is to a first-time reader, per `CONTEXT.md`'s framing discipline. +- New routes have rate limiting. New outbound calls have timeouts and + failure handling that degrades gracefully rather than raising past + valid cached data. +- The change is the smallest version of itself that solves the actual + ticket; flag anything that looks like it grew scope during + implementation. diff --git a/.claude/skills/grill-with-doc/SKILL.md b/.claude/skills/grill-with-doc/SKILL.md new file mode 100644 index 0000000..5b941c3 --- /dev/null +++ b/.claude/skills/grill-with-doc/SKILL.md @@ -0,0 +1,22 @@ +--- +name: grill-with-doc +description: Interrogate a proposed change against this repo's own documented principles before agreeing to build it. Use after research, before writing a spec. +--- + +Take the research output and the proposed change, and check it against +every hard rule in `CLAUDE.md` and every principle in `CONTEXT.md`, +explicitly, one by one, not as a vague gut check. + +Ask, out loud, in the conversation, not just internally: +- Does this add a write endpoint? (Hard no, per `CLAUDE.md`.) +- Does this introduce a dependency on anything beyond `aveproject/ave`? +- Does this change what `/mitigation` can return? +- Does this add a route without rate limiting? +- Does this add an outbound call without a timeout and without failure + handling that preserves the resilience pattern in `_ensure_fresh()`? +- Is there a simpler version of this that does less, given `CONTEXT.md`'s + instruction to keep this service boring on purpose? + +If the proposed change fails any of these, say so directly and propose the +narrower version, rather than building the version as originally +described and hoping it's fine. diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md new file mode 100644 index 0000000..aafd2a0 --- /dev/null +++ b/.claude/skills/implement/SKILL.md @@ -0,0 +1,16 @@ +--- +name: implement +description: Build one ticket from to-tickets. Use after to-tickets, before code-review. +--- + +Implement exactly the ticket's scope, nothing adjacent, even if adjacent +cleanup is tempting. If something adjacent genuinely needs fixing, note it +as a new, separate ticket rather than folding it into this change. + +Write or update tests alongside the change, not after. This is a small +enough codebase that untested logic is a real, avoidable risk, not an +acceptable shortcut. + +Run `python3 -m py_compile main.py` at minimum before considering the +implementation done; a syntax error caught in code review that a +thirty-second local check would have caught is wasted review time. diff --git a/.claude/skills/research/SKILL.md b/.claude/skills/research/SKILL.md new file mode 100644 index 0000000..3da3e1e --- /dev/null +++ b/.claude/skills/research/SKILL.md @@ -0,0 +1,17 @@ +--- +name: research +description: Investigate a task, bug, or question before writing any code. Use before starting work on anything non-trivial. +--- + +Read `CONTEXT.md`, `ARCHITECTURE.md`, and `CLAUDE.md` first, every time, +even if this feels repetitive; this repo is small enough that skipping +this step saves little time and risks missing a hard rule. + +Then investigate the actual codebase: read `main.py` in full, not just the +section that seems relevant, since this service is small enough that +"just the relevant part" often misses a hard rule stated elsewhere (rate +limiting, resilience patterns, the neutrality boundary on `/mitigation`). + +Output: a short written summary of what was found, what's actually being +asked for, and which existing file(s) and pattern(s) the work should +follow. Do not write implementation code in this step. diff --git a/.claude/skills/to-spec/SKILL.md b/.claude/skills/to-spec/SKILL.md new file mode 100644 index 0000000..417d22a --- /dev/null +++ b/.claude/skills/to-spec/SKILL.md @@ -0,0 +1,12 @@ +--- +name: to-spec +description: Turn a grilled-out, validated understanding into a short written spec. Use after grill-with-doc, before to-tickets. +--- + +Write a spec covering: what changes, which file(s), what the new behavior +is, what stays the same, and which of `CLAUDE.md`'s rules were checked and +confirmed compatible in the grill-with-doc step. + +Keep it short. This is a small service; a spec longer than the change it +describes is a sign the change should be split into smaller pieces, not a +sign the spec needs more detail. diff --git a/.claude/skills/to-tickets/SKILL.md b/.claude/skills/to-tickets/SKILL.md new file mode 100644 index 0000000..3dc2e24 --- /dev/null +++ b/.claude/skills/to-tickets/SKILL.md @@ -0,0 +1,13 @@ +--- +name: to-tickets +description: Break a spec into independently-shippable tasks. Use after to-spec, before implement. +--- + +Split the spec into the smallest set of independently-committable changes, +matching this project's own established convention: one task, one branch, +one PR per concern, never bundling unrelated fixes into a single commit +just because they were discussed in the same session. + +Each ticket should be small enough to review in one sitting. If a ticket +touches both `main.py` logic and a documentation file, consider whether +those are actually two tickets. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..699d01c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6dd6405 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: "CodeQL" + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "23 4 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["python"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..dbf6b42 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,38 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + - cron: "30 1 * * 6" + push: + branches: ["main"] + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + actions: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@v2.4.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF results + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8cb5e98 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: local + hooks: + - id: no-em-dash + name: no em dashes anywhere + entry: bash -c '! grep -rn "\xe2\x80\x94" --include="*.py" --include="*.md" --include="*.yaml" --include="*.yml" .' + language: system + pass_filenames: false + + - id: no-vendor-coupling + name: no stray unexplained vendor references + entry: bash -c '! grep -rniE "piranha" --include="*.py" --include="*.md" .' + language: system + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fc2088f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project are documented here. Format loosely +follows [Keep a Changelog](https://keepachangelog.com/). + +## [1.0.0] - initial release + +### Added + +- `GET /`, `GET /records`, `GET /records/{ave_id}`, + `GET /records/{ave_id}/mitigation`, `GET /search`. +- In-memory cache of `aveproject/ave`'s consolidated record dump, fetched + from the unversioned `dist/ave-records-latest.json` alias and refreshed + every 15 minutes, so this service never needs a code change when the + schema version bumps. +- Resilience baked in from the start: a refresh failure serves the last + known-good cache instead of failing every route, and a failed initial + fetch starts the service degraded (empty cache, retries on first + request) rather than crash-looping. +- `cache_age_seconds` in the `GET /` response, surfacing cache staleness + rather than hiding it. +- Rate limiting (60 req/min per IP) and basic security headers + (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`). +- `CONTEXT.md`, `LANGUAGE.md`, `ARCHITECTURE.md`, `CLAUDE.md`, + `docker-compose.yml`, `.pre-commit-config.yaml`. +- Six custom skills under `.claude/skills/`: `research`, `grill-with-doc`, + `to-spec`, `to-tickets`, `implement`, `code-review`. +- CodeQL, Dependabot, and OpenSSF Scorecard CI workflows. +- `GOVERNANCE.md`, `SECURITY.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`. + +### Notes + +- Repo is public, matching `GOVERNANCE.md`'s own stated principle that any + implementer may run their own instance from this source. +- Hosting: evaluated Vercel (rejected, serverless-only Python is + incompatible with the in-memory cache design), Cloud Run, and Render; + Render chosen as primary. See `ARCHITECTURE.md`'s ADR section for the + full reasoning. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0d41df3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at aveproject.org@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..93f82d3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +This is a small, deliberately minimal service. Before opening a PR that +adds a feature, consider whether it belongs here at all: this repo's job is +read-only lookup over AVE's published data, nothing more. Feature requests +that turn this into something bigger (a write API, an auth layer, a +database) are likely better served by a different, separate service someone +builds against `aveproject/ave`'s raw data directly, not an addition to this +one. + +## Local development + +```bash +pip install -r requirements.txt +uvicorn main:app --reload +``` + +## Before opening a PR + +- Run the validation commands in `README.md` against your local instance. +- Confirm CodeQL and Scorecard checks pass in CI; they run automatically on + every PR. +- Keep changes to one concern per PR, matching how this repo's own initial + setup was structured, one task, one branch, one PR. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..01005b7 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,29 @@ +# Governance + +This repo is a reference implementation of the AVE standard, not the +standard itself. Its governance is intentionally lighter than +[aveproject/ave](https://github.com/aveproject/ave)'s, which governs the +standard's own succession, decision process, and trademark terms; see that +repo's own GOVERNANCE.md for those. + +## Maintainer + +Maintained by the AVE Project. Contact: aveproject.org@gmail.com. + +## Decision process + +Small, low-stakes changes (dependency bumps, documentation fixes, minor +endpoint additions that don't change existing behavior) may be merged by +the maintainer directly. Anything that changes an existing endpoint's +response shape, removes an endpoint, or adds authentication should be +raised as an issue for discussion before a PR is opened, since those are +the kinds of changes that break something a second implementer may already +depend on. + +## This is not the only reference implementation, and doesn't need to be + +Nothing about the AVE standard requires this specific service to exist or +stay available. Anyone may run their own instance from this repo's source, +or write an entirely different one against `aveproject/ave`'s raw record +data directly. If this instance ever becomes unmaintained, the standard +itself is unaffected; that separation is the point. diff --git a/README.md b/README.md index 14c6dfa..ea8ac70 100644 --- a/README.md +++ b/README.md @@ -1 +1,62 @@ -# ave-api \ No newline at end of file +# ave-api + +Read-only reference implementation of the [AVE](https://aveproject.org) +(Agentic Vulnerability Enumeration) standard's record lookup API. + +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/aveproject/ave-api/badge)](https://securityscorecards.dev/viewer/?uri=github.com/aveproject/ave-api) +[![CodeQL](https://github.com/aveproject/ave-api/actions/workflows/codeql.yml/badge.svg)](https://github.com/aveproject/ave-api/actions/workflows/codeql.yml) + +## What this is, and isn't + +This is **a** reference implementation, not **the** standard. AVE itself, +the records and schema, lives in [aveproject/ave](https://github.com/aveproject/ave). +This service is a thin, read-only lookup layer over that data, provided so +implementers have a working example to call or fork rather than parsing raw +JSON files themselves. Anyone can run their own instance; nothing about AVE +depends on this specific deployment staying available. + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| GET | `/` | Service info, record count, cache age | +| GET | `/records` | All records | +| GET | `/records/{ave_id}` | One record | +| GET | `/records/{ave_id}/mitigation` | The record's neutral `mitigation` object only | +| GET | `/search?q=...` | Substring search across title, description, attack class, behavioral fingerprint | +| GET | `/docs` | Interactive OpenAPI docs (auto-generated) | + +No authentication. No write endpoints. Rate limited to 60 requests/minute +per IP as a basic abuse backstop, generous for any legitimate use. + +## Running locally + +```bash +pip install -r requirements.txt +uvicorn main:app --reload +``` + +## Data source + +Fetches `dist/ave-records-latest.json` from +[aveproject/ave](https://github.com/aveproject/ave) on startup and every 15 +minutes thereafter. This repo holds no copy of the record data itself; +`aveproject/ave` is the single source of truth. + +## Deployment + +Deployed on Render. See `ARCHITECTURE.md`'s ADR section for why Render was +chosen over Cloud Run and Vercel, and what would have to change to revisit +that. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Security + +See [SECURITY.md](SECURITY.md). + +## License + +Apache 2.0, see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7042ebb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Reporting a vulnerability + +Email aveproject.org@gmail.com. Please do not open a public issue for a +security report until a fix is available; give a reasonable window to +respond before public disclosure, informally 90 days unless the report +itself indicates something actively being exploited, in which case sooner +coordinated disclosure is appropriate. + +## Scope + +This service is read-only, unauthenticated, and serves only public data +already published in [aveproject/ave](https://github.com/aveproject/ave). +There is no user data, no write path, and no secrets held by this service +beyond standard deployment credentials. Rate limiting (60 requests/minute +per IP) is in place as a basic abuse backstop; a report that this can be +bypassed is a valid, in-scope finding. Reports of highest value: anything +that could turn this into a write path, an SSRF vector (this service does +make one outbound fetch, to a fixed, hardcoded GitHub raw content URL, not +user-controlled), or a denial-of-service vector beyond what the existing +rate limiting already mitigates. + +## What this is not the right place to report + +A misclassification, wrong severity, or incorrect field in an AVE record +itself is a data-quality issue with the standard, not a security +vulnerability in this API. Report those to +[aveproject/ave](https://github.com/aveproject/ave/issues) instead. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f1ae7a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + ave-api: + build: . + ports: + - "8080:8080" + environment: + - PORT=8080 + restart: unless-stopped diff --git a/main.py b/main.py index a4b8495..2f06119 100644 --- a/main.py +++ b/main.py @@ -110,7 +110,9 @@ async def get_record(request: Request, ave_id: str) -> dict: for record in _cache["records"]: if record.get("ave_id") == ave_id: return record - raise HTTPException(status_code=404, detail=MSG_RECORD_NOT_FOUND.format(ave_id=ave_id)) + raise HTTPException( + status_code=404, detail=MSG_RECORD_NOT_FOUND.format(ave_id=ave_id) + ) @app.get("/records/{ave_id}/mitigation") @@ -126,7 +128,9 @@ async def get_mitigation(request: Request, ave_id: str) -> dict: detail=MSG_MITIGATION_NOT_FOUND.format(ave_id=ave_id), ) return mitigation - raise HTTPException(status_code=404, detail=MSG_RECORD_NOT_FOUND.format(ave_id=ave_id)) + raise HTTPException( + status_code=404, detail=MSG_RECORD_NOT_FOUND.format(ave_id=ave_id) + ) @app.get("/search") From 61813cafbbef9327dceb194e0f4f7d0fa50a7cec Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Tue, 21 Jul 2026 22:11:15 +0700 Subject: [PATCH 4/9] Add test skill and pytest suite (#6) * Add test skill: red-green-refactor TDD for this repo Test through the HTTP interface, not internals; never touch the real network; reset the module-level cache and rate limiter between tests; one behavior per test. Matches this repo's existing research -> grill-with-doc -> to-spec -> to-tickets -> implement shape, slotted in during implement. * Add pytest suite covering all four routes and the resilience pattern 14 tests, following the new test skill: happy paths and 404s for every route, mitigation neutrality (only the mitigation object, 404 when a record has none), search matching across all four documented fields, rate limiting present on a route, and the three resilience behaviors from CLAUDE.md (stale-serve on refresh failure, hard-fail only when the cache was never populated, no crash-loop on a failed initial fetch). All tests monkeypatch _refresh_cache, no real network access. --- .claude/skills/test/SKILL.md | 62 ++++++++++++++++ requirements-dev.txt | 2 + tests/conftest.py | 49 +++++++++++++ tests/test_main.py | 134 +++++++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 .claude/skills/test/SKILL.md create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_main.py diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md new file mode 100644 index 0000000..b4b372a --- /dev/null +++ b/.claude/skills/test/SKILL.md @@ -0,0 +1,62 @@ +--- +name: test +description: Write tests for a change following red-green-refactor TDD, testing behavior through this service's public HTTP interface rather than internals. Use during implement, before considering a ticket done. +--- + +Write the test first. Watch it fail for the right reason, a missing +behavior or a real bug, not a typo or an import error, then write the +minimum code to make it pass, then refactor with the test as a safety +net. Skipping straight to the implementation and testing it afterward is +not TDD, even if a test file ends up next to the code. + +Test through `/records`, `/records/{ave_id}`, `/records/{ave_id}/mitigation`, +and `/search` with FastAPI's `TestClient`, the same way a real caller +would, rather than importing and calling `_refresh_cache()` or +`_ensure_fresh()` directly. A test that reaches into an internal function +locks in that function's shape and breaks on refactors that don't change +any actual behavior; a test that hits the HTTP interface survives them. + +Never let a test touch the real network. `main.py` makes exactly one +outbound call, to `aveproject/ave`'s raw content; monkeypatch +`main._refresh_cache` to populate `main._cache` with a small, fake +fixture of one or two records instead of hitting GitHub. A test suite +that depends on GitHub being up, or on `aveproject/ave`'s current record +content, is not a test suite, it is a flaky integration check wearing a +test's clothes. + +Reset shared state before every test: `main._cache` and `main.limiter`'s +storage are both module-level singletons that persist across the whole +test session unless something clears them. An autouse fixture that +resets both before each test is not optional scaffolding, it is what +makes the rest of the suite trustworthy; without it, test order starts +to matter and failures stop being reproducible. + +One behavior per test, named as a sentence that describes it, +`test_get_mitigation_404s_when_record_has_no_mitigation`, not +`test_mitigation` or `test_2`. If a test's name can't describe what it +checks in one sentence, it's checking more than one thing and should be +split. + +Cover the behaviors that are actually load-bearing for this repo, not +just the happy path: +- Every route's 404 case, worded well enough that whatever it says is + actually useful to whoever hits it. +- `/mitigation` returns the neutral `mitigation` object, and nothing + else, and 404s cleanly when a record has none. +- `/search` matches across all four documented fields (`title`, + `description`, `attack_class`, `behavioral_fingerprint`), + case-insensitively, and returns an empty list rather than an error + for no matches. +- The resilience pattern from `CLAUDE.md`: a refresh failure with a + populated cache serves the stale data (200, not 500); a refresh + failure with an empty cache is a real failure (500, it has to + surface); startup logs and continues on a failed initial fetch rather + than crash-looping. +- Every route carries its `@limiter.limit(...)` decorator; a route + discovered without one while writing its tests is a bug in that + route, not a gap in the test. + +Run `pytest` before considering a ticket done, same bar as +`python3 -m py_compile main.py` in `implement`'s own checklist: a red +suite caught in code review that a local run would have caught is +wasted review time. diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..60e163f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==8.3.3 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e5d752d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +import time + +import pytest +from fastapi.testclient import TestClient + +import main + +FAKE_RECORDS = [ + { + "ave_id": "AVE-0001", + "title": "Prompt injection via tool output", + "description": "An agent trusts unsanitized tool output as instructions.", + "attack_class": "instruction-injection", + "behavioral_fingerprint": "unexpected-tool-call-sequence", + "mitigation": { + "summary": "Treat tool output as untrusted data, not instructions." + }, + }, + { + "ave_id": "AVE-0002", + "title": "Excessive agency", + "description": "An agent takes an irreversible action without confirmation.", + "attack_class": "privilege-escalation", + "behavioral_fingerprint": "unconfirmed destructive action", + "mitigation": None, + }, +] + + +@pytest.fixture(autouse=True) +def isolated_service_state(monkeypatch): + """Every test starts from known cache and rate-limit state, and no test + ever reaches the real network.""" + + async def fake_refresh_cache() -> None: + main._cache["records"] = FAKE_RECORDS + main._cache["fetched_at"] = time.time() + + monkeypatch.setattr(main, "_refresh_cache", fake_refresh_cache) + main._cache["records"] = [] + main._cache["fetched_at"] = 0.0 + main.limiter.reset() + yield + + +@pytest.fixture +def client(): + with TestClient(main.app) as test_client: + yield test_client diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..1c92ca9 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,134 @@ +import time + +from fastapi.testclient import TestClient + +import main +from conftest import FAKE_RECORDS + + +def test_index_reports_service_metadata(client): + response = client.get("/") + + assert response.status_code == 200 + body = response.json() + assert body["name"] == "AVE Reference API" + assert body["record_count"] == len(FAKE_RECORDS) + assert "cache_age_seconds" in body + + +def test_list_records_returns_all_cached_records(client): + response = client.get("/records") + + assert response.status_code == 200 + assert len(response.json()) == len(FAKE_RECORDS) + + +def test_get_record_returns_matching_record(client): + response = client.get("/records/AVE-0001") + + assert response.status_code == 200 + assert response.json()["ave_id"] == "AVE-0001" + + +def test_get_record_404s_for_unknown_id(client): + response = client.get("/records/does-not-exist") + + assert response.status_code == 404 + assert "does-not-exist" in response.json()["detail"] + + +def test_get_mitigation_returns_only_the_mitigation_object(client): + response = client.get("/records/AVE-0001/mitigation") + + assert response.status_code == 200 + assert response.json() == { + "summary": "Treat tool output as untrusted data, not instructions." + } + + +def test_get_mitigation_404s_when_record_has_no_mitigation(client): + response = client.get("/records/AVE-0002/mitigation") + + assert response.status_code == 404 + + +def test_get_mitigation_404s_for_unknown_id(client): + response = client.get("/records/does-not-exist/mitigation") + + assert response.status_code == 404 + + +def test_search_matches_title_case_insensitively(client): + response = client.get("/search", params={"q": "PROMPT"}) + + assert response.status_code == 200 + assert [r["ave_id"] for r in response.json()] == ["AVE-0001"] + + +def test_search_matches_across_attack_class_and_behavioral_fingerprint(client): + response = client.get("/search", params={"q": "unconfirmed"}) + + assert [r["ave_id"] for r in response.json()] == ["AVE-0002"] + + +def test_search_returns_empty_list_for_no_match(client): + response = client.get("/search", params={"q": "nothing-matches-this"}) + + assert response.status_code == 200 + assert response.json() == [] + + +def test_every_route_is_rate_limited(client): + for _ in range(60): + response = client.get("/records") + assert response.status_code == 200 + + response = client.get("/records") + + assert response.status_code == 429 + + +def test_startup_does_not_crash_when_initial_fetch_fails(monkeypatch): + async def failing_refresh() -> None: + raise RuntimeError("simulated network failure") + + monkeypatch.setattr(main, "_refresh_cache", failing_refresh) + + with TestClient(main.app) as test_client: + response = test_client.get("/") + + assert response.status_code == 200 + assert response.json()["record_count"] == 0 + + +def test_refresh_failure_serves_stale_cache_instead_of_failing(client, monkeypatch): + # `client` already has a populated, fresh cache via the autouse fixture; + # push it past REFRESH_INTERVAL_SECONDS so the next request tries a + # refresh, then make that refresh fail. + main._cache["fetched_at"] = time.time() - main.REFRESH_INTERVAL_SECONDS - 1 + + async def failing_refresh() -> None: + raise RuntimeError("simulated transient network failure") + + monkeypatch.setattr(main, "_refresh_cache", failing_refresh) + + response = client.get("/records") + + assert response.status_code == 200 + assert len(response.json()) == len(FAKE_RECORDS) + + +def test_refresh_failure_is_a_real_failure_when_cache_was_never_populated( + monkeypatch, +): + async def failing_refresh() -> None: + raise RuntimeError("simulated network failure, never succeeded") + + monkeypatch.setattr(main, "_refresh_cache", failing_refresh) + main._cache["records"] = [] + main._cache["fetched_at"] = 0.0 + + with TestClient(main.app, raise_server_exceptions=False) as test_client: + response = test_client.get("/records") + + assert response.status_code == 500 From 066fb4788a94ddb0985ffb1de5e61775d112b2d0 Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Tue, 21 Jul 2026 22:48:39 +0700 Subject: [PATCH 5/9] Trigger CodeQL and Scorecard on push to develop too (#15) Both previously only ran on push to main. Since develop is where work actually lands first, scanning stopped there meant a window between merge to develop and the next develop-to-main sync where new code had no CodeQL or Scorecard coverage at all. --- .github/workflows/codeql.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6dd6405..c516de0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,7 @@ name: "CodeQL" on: push: - branches: ["main"] + branches: ["main", "develop"] pull_request: branches: ["main"] schedule: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index dbf6b42..329e4e1 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -5,7 +5,7 @@ on: schedule: - cron: "30 1 * * 6" push: - branches: ["main"] + branches: ["main", "develop"] permissions: read-all From 7faac82ec703adc401db1b0250504f4e9762b156 Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Tue, 21 Jul 2026 23:47:19 +0700 Subject: [PATCH 6/9] Fix Dockerfile: copy constants.py, not just main.py (#18) The build never copied constants.py after main.py started importing from it, so the container crashed on startup with ModuleNotFoundError inside Docker even though python3 -m py_compile passed locally (that check never runs inside the actual container). Verified by building the image and running it: previously exited immediately with ModuleNotFoundError: No module named 'constants'; now starts, binds the port, and serves real data. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e611a99..0d0b107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py . +COPY main.py constants.py . ENV PORT=8080 EXPOSE 8080 CMD exec uvicorn main:app --host 0.0.0.0 --port ${PORT} From 2a0ba1070463f4711df25a2118ba10c878922f21 Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Wed, 22 Jul 2026 22:13:45 +0700 Subject: [PATCH 7/9] Fix Dockerfile: trailing slash on multi-source COPY destination (#20) The Dockerfile-missing-constants.py fix (#18) merged with a COPY line that builds fine locally under BuildKit (which normalizes . to a directory) but fails on Cloud Build's classic docker builder, which enforces the Dockerfile spec strictly: a multi-source COPY's destination must end with /. Reproduced the exact Cloud Build error locally with DOCKER_BUILDKIT=0, confirmed this fix resolves it under the same strict builder, and confirmed the resulting image starts and serves real data (curl to / returns 200). --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0d0b107..39d424e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py constants.py . +COPY main.py constants.py ./ ENV PORT=8080 EXPOSE 8080 CMD exec uvicorn main:app --host 0.0.0.0 --port ${PORT} From ad538c9d98b9aa8f7bd3fa9422d3a9aaa8df4ace Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Thu, 23 Jul 2026 20:57:44 +0700 Subject: [PATCH 8/9] Update docs to reflect Cloud Run as the live deployment (#21) ARCHITECTURE.md's ADR, README.md's Deployment section, and CHANGELOG.md now say Cloud Run rather than Render, since the first production deployment landed there instead. Kept the original Render reasoning in the ADR rather than erasing it, since ADRs record decisions at the time they were made and the switch itself is part of the record. Confirmed live: GET / on the deployed *.run.app URL returns real data (record_count: 59) matching the local verification from earlier. --- ARCHITECTURE.md | 27 +++++++++++++++++++-------- CHANGELOG.md | 25 +++++++++++++++++++++++++ README.md | 6 +++--- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c8e1b84..fc4501c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -52,11 +52,22 @@ when and why this was added. ## ADR: hosting platform Considered Vercel (rejected: serverless-only Python, no persistent -in-memory state, incompatible with this service's cache design). Chose -Render as primary (zero card on file, real cost is a slower cold start -after idle) over Cloud Run (technically better fit for the persistent- -container model, but requires a card on file even though usage should -stay within the free tier). See `README.md` deployment section for the -current live choice; check there before assuming this ADR reflects where -it's actually running today, ADRs record reasoning at decision time, they -don't self-update. +in-memory state, incompatible with this service's cache design). +Originally chose Render as primary (zero card on file, real cost was a +slower cold start after idle) over Cloud Run (technically better fit +for the persistent-container model, but requires a card on file even +though usage should stay within the free tier). + +Revisited for the first production deployment and switched to Cloud +Run: the card-on-file requirement was accepted deliberately, with a $1 +budget alert as the concrete mitigation (a service expected to cost $0 +means any alert firing at all is the signal, not a soft limit), in +exchange for the persistent-container model actually matching this +service's in-memory cache design instead of working around Render's +sleep-and-wake cycle. `--min-instances 0` keeps it free at idle the +same way Render's sleep did, just without the cold-start latency on +wake. + +See `README.md`'s deployment section for the current live choice; check +there before assuming this ADR reflects where it's actually running +today, ADRs record reasoning at decision time, they don't self-update. diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2088f..e7ad9c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Changed + +- Hosting switched from the planned Render deployment to Google Cloud + Run for the first production deployment (`--min-instances 0`, + `--max-instances 3`, `us-central1`), accepting the card-on-file + tradeoff in exchange for a persistent-container model that matches + this service's in-memory cache design. A $1 budget alert is the + concrete mitigation. See `ARCHITECTURE.md`'s ADR section for the full + reasoning. + +### Fixed + +- `Dockerfile` only copied `main.py`, never `constants.py`, so the + built image crashed on startup with `ModuleNotFoundError` before + `uvicorn` ever bound to the port. Caught by the first real deploy + attempt; `python3 -m py_compile main.py` never catches this since it + runs against the full checked-out repo, not the trimmed set of files + the Dockerfile actually copies into the image. +- The fix for the above (`COPY main.py constants.py .`) built fine + locally under BuildKit but failed on Cloud Build's classic builder, + which enforces the Dockerfile spec strictly: a multi-source `COPY`'s + destination must end with `/`. + ## [1.0.0] - initial release ### Added diff --git a/README.md b/README.md index ea8ac70..b2b289e 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ minutes thereafter. This repo holds no copy of the record data itself; ## Deployment -Deployed on Render. See `ARCHITECTURE.md`'s ADR section for why Render was -chosen over Cloud Run and Vercel, and what would have to change to revisit -that. +Deployed on Google Cloud Run (`--min-instances 0`, scales to zero at +idle). See `ARCHITECTURE.md`'s ADR section for the full reasoning, +including why Render was the original choice and what changed. ## Contributing From c4222addfcf313243c70a34457ddab958e1f6b92 Mon Sep 17 00:00:00 2001 From: Chak Saray Date: Thu, 23 Jul 2026 21:22:27 +0700 Subject: [PATCH 9/9] Revert Scorecard to main-only push trigger (#22) ossf/scorecard-action only supports running against the repo's default branch: a push to develop failed the action outright with "validating options: only default branch is supported" / "Only the default branch main is supported." CodeQL has no such restriction and keeps running on both main and develop; this reverts only the Scorecard side of the earlier change. --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 2b33ff5..8a64b59 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -5,7 +5,7 @@ on: schedule: - cron: "30 1 * * 6" push: - branches: ["main", "develop"] + branches: ["main"] permissions: read-all