Content
# AIRS runtime API-intercept for Amazon API Gateway + Lambda (MCP backend)
A minimal, deployable reference showing the **one correct place** to wire a Prisma AIRS runtime
scan into an Amazon API Gateway + Lambda architecture that fronts an MCP server: the
**integration Lambda**, not the Lambda authorizer.
It exists because this is the exact thing that trips teams up on AWS AIRS POVs. AIRS "API
intercept" is a synchronous call *your* code makes to the AIRS Scan API; you then enforce the
verdict. People reach for the Lambda authorizer first and it cannot work, because **an authorizer
never sees the request body** (where the prompt lives). Deploy this, run the tests, and you have a
known-good stack to diff a broken one against.
## Architecture
```
Authorization: Bearer <token>
client ──POST /mcp──▶ Amazon API Gateway
│
├─▶ Lambda AUTHORIZER (authn only; gets the token, NOT the body)
│ └─ Allow / Deny
│
└─▶ AIRS GATEWAY Lambda ◀── AIRS scanning lives HERE
1. parse event["body"] (string; base64 if isBase64Encoded)
2. PRE-CALL scan prompt ──▶ AIRS Scan API
action=block ─▶ 403, stop
3. invoke ──▶ MCP server Lambda (JSON-RPC backend)
4. POST-CALL scan response ──▶ AIRS Scan API (catches DLP)
action=block ─▶ 403
5. return MCP result
```
## Why the authorizer is the wrong place (the teaching point)
| Where | Sees the request body? | Can block? | Right place for AIRS? |
| --- | --- | --- | --- |
| Lambda authorizer (TOKEN) | No - only the token header | Allow/Deny only | No |
| Lambda authorizer (REQUEST) / HTTP API authorizer | No - headers/query/path/context, never the body | Allow/Deny only | No |
| API Gateway mapping template (VTL) | Yes, but cannot make an outbound HTTPS call to AIRS | No | No |
| **Integration / handler Lambda** | **Yes** | **Yes** | **Yes** |
The prompt (or MCP tool-call) is in the body. Only the integration Lambda has the body *and* the
ability to short-circuit with a block. So that is where the AIRS Scan API call goes. The authorizer
keeps doing authn. See `functions/authorizer/app.py` for the explicit "no body here" note.
## What you need
- AWS SAM CLI (`brew install aws-sam-cli`) and AWS credentials for a sandbox account.
- A Prisma AIRS **runtime Scan API key** (the data-plane `x-pan-token`, not the management creds).
- An AIRS **security profile name** to scan against.
## Deploy
```bash
sam build
sam deploy --guided
# answer the prompts:
# AirsApiKey = <your AIRS runtime scan API key>
# AirsProfileName = <your AIRS profile name> <-- REQUIRED (see gotcha #2)
# AirsEndpoint = (default US; use your regional URL if EU/etc.)
# AirsFailOpen = false (block on AIRS error/timeout)
# AirsAppName = airs-apigw-mcp-intercept
# DemoBearerToken = <pick one> (the demo authorizer accepts this)
```
The `ApiUrl` output is your endpoint.
### Deploy without SAM (SCP-restricted accounts)
Some managed AWS Organizations block the SAM transform macro (you'll see
`is not authorized to perform: cloudformation:CreateChangeSet on resource:
arn:aws:cloudformation:...:aws:transform/Serverless-2016-10-09`). In that case use the plain
CloudFormation template `cloudformation.yaml` (no transform) via the helper script:
```bash
export CODE_BUCKET="an-s3-bucket-you-own" # in your region
export AIRS_API_KEY="$PANW_AI_SEC_API_KEY"
export AIRS_PROFILE_NAME="my-dlp-block-profile"
export DEMO_BEARER_TOKEN="pick-one"
./scripts/deploy-cfn.sh # zips, uploads, create/update-stacks, prints ApiUrl
```
This deploys the identical architecture with only `CAPABILITY_IAM` (no macro), so it works where
`sam deploy` is blocked. **This is the path validated end-to-end on a live AIRS tenant** (all six
test outcomes below, including the stage=response DLP catch).
## Test
```bash
export API_URL="<ApiUrl output>"
export TOKEN="<the DemoBearerToken you chose>"
./scripts/test.sh
```
Expected:
| # | Request | Result |
| --- | --- | --- |
| 1 | benign `get_weather` | `200` + weather (flowed through AIRS to MCP) |
| 2 | malicious instruction in `echo` args | `403 blocked_by prisma-airs, stage=prompt` (PRE-CALL) |
| 3a | `get_account_details` (another customer's record) | `403 stage=prompt` (the tool-call itself trips a topic/agent detector) |
| 3b | `view_profile` (benign call, PII only in the response) | `403 stage=response` (POST-CALL / DLP) |
| 4 | wrong token | `403` from the authorizer (AIRS never runs) |
| 5 | no token | `401` from the authorizer |
Test 3b is the one that matters most in a competitive POV: the call looks benign, the secret/PII is
only in the **response**, and only a post-call scan catches it. Prompt-only filtering never sees it.
(3a vs 3b shows AIRS catching the same sensitive data at two different stages.)
The exact detector that fires depends on your AIRS profile. These outcomes were validated live on a
profile with prompt-injection / agent-security / topics and **DLP set to block**. With DLP at
*alert* (not block), test 3b returns `200` and the PII is logged, not blocked - pick a DLP-block
profile to demo the response catch.
## The two gotchas that break hand-rolled versions
1. **`event["body"]` is a string** (base64 when `event["isBase64Encoded"]` is true). You must
`json.loads` it and pull out the actual prompt before scanning. Forwarding the API Gateway
event envelope, or the still-stringified body, is the classic "wrong payload to AIRS."
Handled in `_parse_body()`.
2. **`profile_name` is mandatory.** With no `ai_profile`, the Scan API returns 400 and a naive
integration **silently fails open**. This reference logs loudly and fails closed by default
(`AirsFailOpen=false`). Handled in `_scan()`.
## Adapt to an existing API Gateway + Lambda
If the customer already has API Gateway → (authorizer) → MCP/Lambda, you do not rewrite it:
1. Leave the authorizer alone (authn only).
2. Put a small "AIRS gateway" Lambda **between** API Gateway and the MCP backend (or inline the
four scan steps at the top/bottom of their existing handler). Copy `functions/airs_gateway/app.py`.
3. In it: `json.loads(event["body"])` → scan the prompt → on allow, call the MCP backend → scan
the response → return.
4. Set `AIRS_API_KEY` and `AIRS_PROFILE_NAME` (plus endpoint / fail-open).
5. Point API Gateway's integration at the AIRS gateway Lambda.
If they would rather not hand-roll MCP scanning at all, `pan-mcp-relay` is the purpose-built MCP
scanning proxy - but for "runtime intercept in our own API Gateway + Lambda," this is the pattern.
## Files
```
template.yaml SAM stack (recommended; one `sam deploy`)
cloudformation.yaml plain-CFN equivalent (no transform) for SCP-restricted accounts
functions/authorizer/app.py authn only; documents why AIRS cannot live here
functions/airs_gateway/app.py the AIRS intercept (pre + post scan, enforce, fail-open/closed)
functions/mcp_server/app.py tiny JSON-RPC MCP backend (benign / malicious / PII tools)
scripts/deploy-cfn.sh zip + upload + deploy the plain-CFN version (no SAM CLI needed)
scripts/test.sh curl the scenarios above
docs/architecture.md deeper request-flow walkthrough
```
## Notes
- Lambdas use only the Python standard library (`urllib`) plus `boto3` (already in the runtime),
so there is no dependency layer and `sam build` needs no `pip` step.
- This is a reference / POV asset on a deliberately vulnerable backend. Do not point it at
anything real without changing the seeded data and the demo token.