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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
__pycache__/
*.pyc
service.yaml
44 changes: 35 additions & 9 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,23 @@
```
aveproject/ave (source of truth)
|
| raw.githubusercontent.com fetch, on startup + every 15 min
| raw.githubusercontent.com: latest records + manifest,
| on startup + every 15 min
| github.com release assets: a pinned release, on first use
| api.github.com: the list of releases, at most hourly
v
in-memory cache (a Python dict, nothing more)
in-memory caches (Python dicts, nothing more): latest, a few pinned
releases, the releases list
|
| four read-only routes
| read-only routes, under /v1 (the unversioned paths are aliases)
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
GET /v1/ service info, api_version, catalog, cache_age_seconds
GET /v1/records full list
GET /v1/records/{ave_id} one record
GET /v1/records/{ave_id}/mitigation one record's neutral mitigation object only
GET /v1/search?q= substring match across title, description, attack_class,
behavioral_fingerprint
GET /v1/releases the catalog releases, and which can be pinned
```

## Design decisions worth knowing before changing anything
Expand Down Expand Up @@ -45,6 +50,27 @@ 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.

**Every data response names its catalog.** A catalog is the latest data
(`AVE-Catalog: latest`) or one release (`AVE-Catalog: v1.2.0`), and its
`sha256`, `schema_version`, `generated_at` and `source_commit` come from the
manifest beside the records file, never from guesses. The latest records and
manifest are fetched separately, so a refresh that gets two that do not match
(a commit landing between the two fetches) is refused like any other failed
refresh, and the last known-good cache keeps serving. The `ETag` is derived
from the records `sha256`, the path without its `/v1` prefix and the query,
so an alias and its `/v1` path share one.

**A pinned release is verified before it is served, and only then cached.**
`?release=` downloads `SHA256SUMS`, the manifest and the records file, and
runs the same checks as `aveproject/ave`'s `scripts/check_release_assets.py`.
A release that fails is a `502` and is not cached, so a later request tries
again. Releases never change, so the cache has no expiry, only a size bound
(`RELEASE_CACHE_SIZE`, oldest use evicted first).

**The releases list spends as little of GitHub's unauthenticated rate limit
as it can**: cached for an hour, then revalidated with `If-None-Match`, so an
unchanged list costs a `304`. A failed refresh keeps serving the last list.

**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.
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,32 @@ follows [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

### Added

- Version 1 of the contract under `/v1/`: `/v1/`, `/v1/records`,
`/v1/records/{ave_id}`, `/v1/records/{ave_id}/mitigation`, `/v1/search`.
The unversioned paths stay as aliases with identical behaviour (#49).
- Every data response names the catalog it came from: `AVE-API-Version`,
`AVE-Catalog` (`latest` or a release tag), `AVE-Records-SHA256`,
`AVE-Schema-Version`, `AVE-Source-Commit` when known, plus `ETag`,
`Last-Modified` and `Cache-Control`. A matching `If-None-Match` returns
`304`. The root adds `api_version`, `schema_version` and a `catalog`
object read from the latest manifest.
- `?release=vX.Y.Z` on every data route serves that catalog release of
`aveproject/ave`, verified against its `SHA256SUMS` and manifest before
use and cached in memory (a few releases). An unknown release is a `404`;
one that fails verification is a `502` and is not cached.
- `GET /v1/releases`: the catalog releases, from the GitHub Releases API,
cached for an hour and revalidated with conditional requests.

### Changed

- A refresh of the latest data now also fetches its manifest, and a records
file that does not match the manifest's `sha256` or `record_count` counts
as a failed refresh (the last known-good cache keeps serving).
- CORS exposes the `AVE-*` headers and `ETag`, so browser scripts can read
them.

- 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
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ mid-session.

## Resilience rules

- Every network call this service makes (currently: one, the fetch to
`aveproject/ave`) must handle failure without taking down unrelated
- Every network call this service makes (currently: the latest records
and manifest from `aveproject/ave`, a pinned release's assets, and the
GitHub Releases API) 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
Expand All @@ -34,8 +35,8 @@ mid-session.
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.
`timeout=10` through `_client()`; any new outbound call needs an explicit
timeout of its own, never the client library's default.

## Security rules

Expand Down
9 changes: 5 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ 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.
A handful of endpoints under `/v1`: list records, get one record, get one
record's neutral mitigation object, search, and list the catalog releases
that can be pinned. No auth, no write path, ever.

## What this is not

Expand All @@ -28,8 +29,8 @@ 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
fetches it (or a pinned release of it), caches it in memory, and serves it
back out through a few 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
Expand Down
95 changes: 84 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Read-only reference implementation of the [AVE](https://aveproject.org)
## Quick start

```bash
curl -s https://api.aveproject.org/records/AVE-2026-00046 | jq .
curl -s https://api.aveproject.org/v1/records/AVE-2026-00046 | jq .
```

## What this is, and isn't
Expand All @@ -29,17 +29,88 @@ depends on this specific deployment staying available.

## Endpoints

Version 1 of the contract lives under `/v1`:

| 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 | `/v1/` | Service info, API version, the catalog being served, cache age |
| GET | `/v1/records` | All records, deprecated ones included with their `status` |
| GET | `/v1/records/{ave_id}` | One record |
| GET | `/v1/records/{ave_id}/mitigation` | The record's neutral `mitigation` object only |
| GET | `/v1/search?q=...` | Substring search across title, description, attack class, behavioral fingerprint |
| GET | `/v1/releases` | The catalog releases of `aveproject/ave`, and which can be pinned |
| 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.
The unversioned paths (`/`, `/records`, `/records/{ave_id}`,
`/records/{ave_id}/mitigation`, `/search`) are aliases of their `/v1` paths
with identical bodies and headers, so existing callers keep working. New
callers should use `/v1`.

A deprecated record is served with its `status`; only an id that was never
published is a `404`.

No authentication. No write endpoints. Rate limited to 60 requests a minute
per IP for each path, as a basic abuse backstop, generous for any legitimate
use.

## Which data you got

Every data response (records, one record, a mitigation, a search) carries
headers saying which catalog it came from:

| Header | Value |
|---|---|
| `AVE-API-Version` | `1`, the contract version (on every response) |
| `AVE-Catalog` | `latest`, or the release tag, for example `v1.2.0` |
| `AVE-Records-SHA256` | SHA-256 of the records file the answer came from |
| `AVE-Schema-Version` | The schema version every record in it declares |
| `AVE-Source-Commit` | The `aveproject/ave` commit it was built from, when known (releases) |
| `ETag`, `Last-Modified` | For conditional requests; `Last-Modified` is the file's `generated_at` |

`GET /v1/` reports the same for the latest data in a `catalog` object, read
from the manifest that sits next to the records file.

## Pinning a release

Add `?release=vX.Y.Z` to any data route to read a fixed catalog release
instead of the latest data:

```bash
curl -s "https://api.aveproject.org/v1/records/AVE-2026-00046?release=v1.2.0"
```

The service downloads that release's `ave-records.json`, checks it against
the release's `SHA256SUMS` and manifest before serving anything from it, and
keeps the last few releases in memory. An unknown release is a `404`; a
release whose files do not agree is a `502` and is not cached. Releases from
`v1.2.0` on can be pinned; `GET /v1/releases` lists them. These checks prove
the files belong together, not who published them; to check provenance, use
`gh attestation verify` on a downloaded release.

## Caching

Send the `ETag` back as `If-None-Match` and an unchanged answer comes back
as an empty `304`:

```bash
curl -s -D - -o /dev/null https://api.aveproject.org/v1/records/AVE-2026-00046
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "<etag from above>"' \
https://api.aveproject.org/v1/records/AVE-2026-00046
```

The latest data is `Cache-Control: public, max-age=300`; a pinned release
never changes, so it is `public, max-age=86400, immutable`.

## Not for scanning hot paths

This service runs as a single instance that scales to zero when idle, so the
first request after a quiet spell waits for a cold start, and every caller
shares one instance and its rate limit. Use it for lookups, links and
spot checks. A scanner that resolves `ave_id` values on every finding should
download a catalog release once (see
[Releases and pinning](https://github.com/aveproject/ave#releases-and-pinning)
in `aveproject/ave`) and read it locally.

## Running locally

Expand All @@ -50,10 +121,12 @@ uvicorn main:app --reload

## Data source

Fetches `dist/ave-records-latest.json` from
Fetches `dist/ave-records-latest.json` and its manifest 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.
minutes thereafter. A pinned release is downloaded from that repo's GitHub
Releases on first use, and the list of releases comes from the GitHub
Releases API, cached for an hour. This repo holds no copy of the record data
itself; `aveproject/ave` is the single source of truth.

## Deployment

Expand Down
20 changes: 20 additions & 0 deletions constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
SERVICE_NAME = "AVE Reference API"
STANDARD_URL = "https://aveproject.org"
REPO_URL = "https://github.com/aveproject/ave"
API_VERSION = "1"

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}"
Expand All @@ -14,3 +15,22 @@
MSG_INDEX_REFRESH_FAILED = (
"Index could not refresh the cache, reporting the last known state: {error}"
)
MSG_LATEST_SHA256_MISMATCH = (
"The latest records file does not match the sha256 in its manifest; "
"the two were probably fetched from different commits"
)
MSG_LATEST_COUNT_MISMATCH = (
"The latest records file does not hold the record_count in its manifest"
)
MSG_RELEASE_NOT_FOUND = (
"No catalog release {tag} with ave-records.json, ave-records.manifest.json "
"and SHA256SUMS; see /v1/releases for the releases that can be pinned"
)
MSG_RELEASE_UNVERIFIED = "Release {tag} failed verification and was not used: {reason}"
MSG_RELEASE_FETCH_FAILED = "Release {tag} could not be downloaded: {error}"
MSG_RELEASES_UNAVAILABLE = (
"The list of releases could not be fetched from GitHub and none is cached yet"
)
MSG_RELEASES_REFRESH_FAILED = (
"Releases refresh failed, continuing to serve the last known list: {error}"
)
Loading
Loading