diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile new file mode 100644 index 0000000..322035e --- /dev/null +++ b/.oss-scanner/Dockerfile @@ -0,0 +1,36 @@ +# Build for Anthropic's OSS Scanner (anthropics/oss-scanner, projects/agent-commerce). +# The scanner builds this with the repository root as the build context. +# Dockerfile.dockerignore next to this file replaces the root .dockerignore, +# which would leave out contracts/lib. Network access exists only during this +# build, so every dependency the tests need is installed here. +FROM node:22-bookworm + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git python3 \ + && rm -rf /var/lib/apt/lists/* + +# Foundry, pinned to the version CI uses (FOUNDRY_VERSION in +# .github/workflows/ci.yml). The local-chain tests spawn a real Anvil and are +# skipped without it. +ARG FOUNDRY_VERSION=v1.7.1 +RUN arch="$(dpkg --print-architecture)" \ + && base="https://github.com/foundry-rs/foundry/releases/download/${FOUNDRY_VERSION}/foundry_${FOUNDRY_VERSION}_linux_${arch}" \ + && curl -fsSL "${base}.tar.gz" -o /tmp/foundry.tar.gz \ + && curl -fsSL "${base}.sha256" -o /tmp/foundry.sha256 \ + && echo "$(cut -d' ' -f1 /tmp/foundry.sha256) /tmp/foundry.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/foundry.tar.gz -C /usr/local/bin anvil forge cast \ + && rm /tmp/foundry.tar.gz /tmp/foundry.sha256 \ + && anvil --version + +# scanner contract: the checkout lives inside the image, at /src +COPY . /src +WORKDIR /src + +# All dependencies, dev included, then the build the package publishes. +RUN npm ci +RUN npm run build + +# Unit, integration and conformance tests (no network needed). A failing test +# does not fail the image. +RUN npm test || echo "WARNING: npm test reported failures" diff --git a/.oss-scanner/Dockerfile.dockerignore b/.oss-scanner/Dockerfile.dockerignore new file mode 100644 index 0000000..9f6719f --- /dev/null +++ b/.oss-scanner/Dockerfile.dockerignore @@ -0,0 +1,23 @@ +# BuildKit uses this file instead of the root .dockerignore when it builds +# .oss-scanner/Dockerfile. The root file is written for the demo images and +# drops contracts/lib, the vendored forge-std the contract tests compile +# against, so it must not apply here. +node_modules +**/node_modules +dist +**/dist +coverage +**/coverage +.git +data +.deploy +contracts/out +contracts/cache +**/*.tsbuildinfo +**/*.log +.env +.env.local +*.sqlite +*.sqlite-* +*.tgz +config.yaml diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md new file mode 100644 index 0000000..f562af7 --- /dev/null +++ b/.oss-scanner/threat_model.md @@ -0,0 +1,76 @@ +# Threat model: Agent Commerce Gateway + +The full security notes are in the repository's `SECURITY.md` and `docs/security.md`. This file summarizes them for the scanner. + +## What this project does and where untrusted input enters + +Agent Commerce Gateway is a self-hosted TypeScript gateway that sits in front of a merchant's existing HTTP API and sells calls to it to AI agents. Agents reach it over plain HTTP (`/api/resources/:id/invoke`), MCP (`/mcp`), A2A (`/a2a`) and, when enabled, ACP checkout routes. Paid resources take x402 v2 (`exact`, EVM / EIP-3009) or MPP payments, and can also require an AP2 Checkout Mandate. The gateway never holds merchant or buyer funds: payments go from the buyer to the merchant's configured address. + +- **Untrusted:** everything a caller sends. + - HTTP, MCP JSON-RPC, A2A JSON-RPC and ACP request bodies. + - Path parameters, query strings and resource inputs. + - `Host` and `Origin` headers. + - Payment proofs: x402 `PAYMENT-SIGNATURE`, MPP `Authorization: Payment …` credentials, and the `_payment` field on MCP and A2A. + - AP2 mandates (SD-JWT). +- **Semi-trusted:** + - responses from the payment facilitator and the RPC node; + - responses from the merchant backend (the built-in executor enforces a timeout and a 1 MiB body cap). +- **Trusted:** the operator's configuration file and environment, including backend URLs, admin and ACP tokens, keys and facilitator settings. + +## Components that matter most / least + +- **Most important:** + - **The payment path:** `src/core` (request pipeline), `src/payments/x402`, `src/payments/mpp`. + - Verify, then replay reservation, then settle, then deliver. + - Every failure must fail closed. + - Delivery must happen at most once per payment authorization. + - **AP2 verification:** `src/authorization/ap2`. + - **Protocol adapters:** `src/protocols/mcp`, `src/protocols/a2a`, `src/protocols/acp`, `src/protocols/http.ts`. + - **Access control and routing:** `src/gateway`. + - Host-header (DNS-rebinding) validation in `access-control.ts`. + - The admin token on `/api/receipts` and `/api/events`. + - The ACP bearer token. + - **Storage** (`src/storage`): receipts, events, replay keys. + - **Secret redaction** in logs and receipts. +- **Lower priority:** + - `src/cli` (operator tooling); + - `src/openapi` (imports an operator-supplied OpenAPI document); + - `src/config`, which still matters because invalid configuration must fail startup. +- **Out of scope:** + - `demo/`, `examples/`, `scripts/`, `contracts/` (local demo chain); + - the deliberately public Anvil development keys; + - the internals of third-party SDKs (x402, MCP SDK, mppx). How the gateway uses them is in scope. + +## How to exercise it + +- `npm test` runs the unit, integration and conformance suites (Vitest). The local-chain suites spawn Anvil, which this image installs. +- `tests/conformance/` drives the MCP, A2A and ACP adapters with the official client SDKs. +- `config-demo.yaml` describes a complete local setup. `npm run agent-commerce -- doctor --config config-demo.yaml` checks it. + +## How we rate severity + +- **Critical:** + - a paid resource delivered without a successfully verified and settled payment; + - a payment that settles to a recipient, amount, asset or network other than the configured one; + - one payment authorization or AP2 mandate unlocking more than one delivery; + - an AP2 requirement bypassed; + - any path that discloses a private key. +- **High:** + - unauthenticated access to the operator routes (`/api/receipts`, `/api/events`) or disclosure of `server.adminToken`; + - an ACP bearer-token bypass; + - a bypass of the Host-header validation; + - credentials, payment proofs or tokens written to logs or receipts despite redaction; + - code execution or path traversal reachable from untrusted input. +- **Medium:** denial of service from unauthenticated input that gets past the configured limits (body size, timeouts, batch handling); disclosure of non-secret configuration. +- **Low:** findings that need a malicious or careless operator configuration; overly detailed error messages without secrets. + +## Anything to leave alone + +`SECURITY.md` → "What the gateway does not protect against" lists behavior that is documented and intended. Please do not report it: +- SSRF through backend URLs the operator configured; +- no buyer vetting or KYC; +- irreversible on-chain settlement; +- the documented divergence between payment and delivery in the `upfront` and `authorization` flows; +- reserved authorizations never being released; +- a compromised host; +- the demo dashboard's `VITE_ADMIN_TOKEN` placeholder. diff --git a/tests/unit/storage-receipts/persistence.test.ts b/tests/unit/storage-receipts/persistence.test.ts index 9d7786c..120a2d0 100644 --- a/tests/unit/storage-receipts/persistence.test.ts +++ b/tests/unit/storage-receipts/persistence.test.ts @@ -125,32 +125,37 @@ describe('schema lifecycle', () => { await store.close(); }); - it('reports a fixed-vocabulary detail (never the raw error message) when the store is unwritable, and logs the raw message', async () => { - const path = join(dir, 'readonly.db'); - const logger = createCapturingLogger(); - const store = createSqliteReceiptStore({ path, logger }); - await store.init(); - - // Permissions change under a live connection (a host mount going - // read-only, say). Re-opening would not reach health(): opening a store - // over an unwritable file already throws in openSqliteDatabase. - chmodSync(path, 0o444); - - const health = await store.health(); - - expect(health.status).toBe('fail'); - expect(health.detail).toBe('store-unwritable'); - // Neither the absolute path nor the raw OS error reaches the detail - expect(health.detail).not.toContain(path); - expect(health.detail).not.toMatch(/EACCES|permission denied/i); - // The raw error goes to the logger instead - expect(logger.warnings.length).toBeGreaterThan(0); - expect(String(logger.warnings[0]?.obj['err'])).toMatch(/EACCES|permission denied/i); - - // The WAL checkpoint on close needs write access - chmodSync(path, 0o644); - await store.close(); - }); + // chmod does not stop root (or Windows) from writing, so the store stays + // writable there; the startup checks below skip under the same condition + it.runIf(process.platform !== 'win32' && process.getuid?.() !== 0)( + 'reports a fixed-vocabulary detail (never the raw error message) when the store is unwritable, and logs the raw message', + async () => { + const path = join(dir, 'readonly.db'); + const logger = createCapturingLogger(); + const store = createSqliteReceiptStore({ path, logger }); + await store.init(); + + // Permissions change under a live connection (a host mount going + // read-only, say). Re-opening would not reach health(): opening a store + // over an unwritable file already throws in openSqliteDatabase. + chmodSync(path, 0o444); + + const health = await store.health(); + + expect(health.status).toBe('fail'); + expect(health.detail).toBe('store-unwritable'); + // Neither the absolute path nor the raw OS error reaches the detail + expect(health.detail).not.toContain(path); + expect(health.detail).not.toMatch(/EACCES|permission denied/i); + // The raw error goes to the logger instead + expect(logger.warnings.length).toBeGreaterThan(0); + expect(String(logger.warnings[0]?.obj['err'])).toMatch(/EACCES|permission denied/i); + + // The WAL checkpoint on close needs write access + chmodSync(path, 0o644); + await store.close(); + }, + ); it('close() is idempotent', async () => { const store = createSqliteReceiptStore({ path: ':memory:' });