From 80ac3601bffd6c7c13c737c9cae361bd5c492b69 Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Fri, 17 Apr 2026 09:10:46 -0400 Subject: [PATCH 01/12] Extend ZAP scan to provider users and state API M2M (#1467, #1468) Route bearer tokens by URL in bearer-token.js: state-api host gets the M2M token, /v1/provider-users/* + /v1/purchases/* + GET attestations-by-id get the provider token, everything else gets the staff token. Workflow runs three auth steps and exposes each token as a distinct env var. main.js takes --mode=staff|provider so the same script handles both user-pool sign-ins from a single unified .env. Requires new GitHub secrets for provider user creds and state auth M2M client; see owasp-zap/README.md for provisioning. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/zap-scan-test.yml | 53 +++++++++++++--- owasp-zap/README.md | 80 +++++++++++++++++------- owasp-zap/authenticator/.env.example | 24 +++++-- owasp-zap/authenticator/get-m2m-token.sh | 31 +++++++++ owasp-zap/authenticator/main.js | 22 ++++--- owasp-zap/data/bearer-token.js | 47 ++++++++++---- owasp-zap/data/test-automation.yml | 9 +++ owasp-zap/manual-scan.sh | 55 ++++++++++++++-- 8 files changed, 261 insertions(+), 60 deletions(-) create mode 100755 owasp-zap/authenticator/get-m2m-token.sh diff --git a/.github/workflows/zap-scan-test.yml b/.github/workflows/zap-scan-test.yml index b399dd5e4..a9d44ed70 100644 --- a/.github/workflows/zap-scan-test.yml +++ b/.github/workflows/zap-scan-test.yml @@ -39,20 +39,52 @@ jobs: run: yarn install --ignore-engines working-directory: 'owasp-zap/authenticator' - - name: Authenticate ZAP User + - name: Authenticate Staff User env: - COGNITO_USER_POOL_ID: ${{ secrets.TEST_COGNITO_USER_POOL_ID_STAFF }} - COGNITO_USER_POOL_CLIENT_ID: ${{ secrets.TEST_WEBROOT_COGNITO_CLIENT_ID_STAFF }} - COGNITO_USERNAME: ${{ secrets.TEST_ZAP_USERNAME_STAFF }} - COGNITO_PASSWORD: ${{ secrets.TEST_ZAP_PASSWORD_STAFF }} + STAFF_COGNITO_USER_POOL_ID: ${{ secrets.TEST_COGNITO_USER_POOL_ID_STAFF }} + STAFF_COGNITO_USER_POOL_CLIENT_ID: ${{ secrets.TEST_WEBROOT_COGNITO_CLIENT_ID_STAFF }} + STAFF_COGNITO_USERNAME: ${{ secrets.TEST_ZAP_USERNAME_STAFF }} + STAFF_COGNITO_PASSWORD: ${{ secrets.TEST_ZAP_PASSWORD_STAFF }} run: | - token=$(node main.js | jq -r '.accessToken') + token=$(node main.js --mode=staff | jq -r '.accessToken') if [[ -z "$token" || "$token" == "null" ]]; then - echo "::error::Failed to obtain authentication token" + echo "::error::Failed to obtain staff authentication token" exit 1 fi - echo "ZAP_AUTH_HEADER_VALUE=$token" >> "$GITHUB_ENV" - echo "Authentication successful" + echo "::add-mask::$token" + echo "ZAP_AUTH_STAFF_TOKEN=$token" >> "$GITHUB_ENV" + working-directory: 'owasp-zap/authenticator' + + - name: Authenticate Provider User + env: + PROVIDER_COGNITO_USER_POOL_ID: ${{ secrets.TEST_COGNITO_USER_POOL_ID_PROVIDER }} + PROVIDER_COGNITO_USER_POOL_CLIENT_ID: ${{ secrets.TEST_COGNITO_CLIENT_ID_PROVIDER }} + PROVIDER_COGNITO_USERNAME: ${{ secrets.TEST_ZAP_USERNAME_PROVIDER }} + PROVIDER_COGNITO_PASSWORD: ${{ secrets.TEST_ZAP_PASSWORD_PROVIDER }} + run: | + token=$(node main.js --mode=provider | jq -r '.accessToken') + if [[ -z "$token" || "$token" == "null" ]]; then + echo "::error::Failed to obtain provider authentication token" + exit 1 + fi + echo "::add-mask::$token" + echo "ZAP_AUTH_PROVIDER_TOKEN=$token" >> "$GITHUB_ENV" + working-directory: 'owasp-zap/authenticator' + + - name: Authenticate State API M2M Client + env: + COGNITO_STATE_AUTH_DOMAIN: ${{ secrets.TEST_COGNITO_STATE_AUTH_DOMAIN }} + STATE_AUTH_CLIENT_ID: ${{ secrets.TEST_STATE_AUTH_CLIENT_ID }} + STATE_AUTH_CLIENT_SECRET: ${{ secrets.TEST_STATE_AUTH_CLIENT_SECRET }} + STATE_AUTH_SCOPES: ${{ secrets.TEST_STATE_AUTH_SCOPES }} + run: | + token=$(./get-m2m-token.sh) + if [[ -z "$token" ]]; then + echo "::error::Failed to obtain state API M2M token" + exit 1 + fi + echo "::add-mask::$token" + echo "ZAP_AUTH_STATE_TOKEN=$token" >> "$GITHUB_ENV" working-directory: 'owasp-zap/authenticator' - name: Enrich OpenAPI specs with valid parameter examples @@ -63,6 +95,9 @@ jobs: python3 owasp-zap/enrich-oas-for-zap.py \ backend/compact-connect/docs/search-internal/api-specification/latest-oas30.json \ backend/compact-connect/docs/search-internal/api-specification/latest-oas30-zap.json + python3 owasp-zap/enrich-oas-for-zap.py \ + backend/compact-connect/docs/api-specification/latest-oas30.json \ + backend/compact-connect/docs/api-specification/latest-oas30-zap.json - name: ZAP Scan uses: zaproxy/action-af@v0.1.0 diff --git a/owasp-zap/README.md b/owasp-zap/README.md index b870798d4..2a8986f78 100644 --- a/owasp-zap/README.md +++ b/owasp-zap/README.md @@ -1,38 +1,76 @@ # OWASP Zed Attack Proxy -[ZAP](https://www.zaproxy.org/) is a security pen testing tool, which this project uses for active scanning for vulnerabilities. To manually run a scan similar to the ones integrated into [our GitHub Actions](../.github/workflows), run `./manual-scan.sh` from a command line. For full details on running the scan locally, see [Manual run ](#manual-run). Below is a brief explanation of the pieces of this integration and what they are for. +[ZAP](https://www.zaproxy.org/) is a security pen testing tool, which this project uses for active scanning for vulnerabilities. To manually run a scan similar to the ones integrated into [our GitHub Actions](../.github/workflows), run `./manual-scan.sh` from a command line. For full details on running the scan locally, see [Manual run](#manual-run). Below is a brief explanation of the pieces of this integration and what they are for. # Authenticator -The [authenticator](./authenticator) directory is a simple NodeJS script that leverages [aws-amplify](https://www.npmjs.com/package/aws-amplify) to quickly log a test user in and acquire an token for authentication. This script requires that the Cognito UserPool Client be configured to enable the [Secure Remote Pasword (SRP)](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol) authentication flow as part of a 'VULNERABLE' security profile, which can only be activated in pre-production environments. See [Set up](#set-up) for discussion of the profile. +The [authenticator](./authenticator) directory contains helpers for obtaining bearer tokens against the three Cognito pools CompactConnect uses: + +- `main.js` — a NodeJS script leveraging [aws-amplify](https://www.npmjs.com/package/aws-amplify) for user sign-in against the **Staff Users** and **Provider Users** pools. It accepts `--mode=staff` or `--mode=provider` to select which set of `.env` variables to use. Both pools must have the [Secure Remote Password (SRP)](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol) flow enabled, which happens automatically under the `VULNERABLE` security profile (see [Set up](#set-up)). +- `get-m2m-token.sh` — a shell script that performs the OAuth2 `client_credentials` grant against the **State Auth** pool for machine-to-machine access to `state-api.test.compactconnect.org`. # Data -The [data](./data) folder contains configuration and automation data files that are used to control ZAP's scan. This includes an HttpSender script and an automation YML file. The HTTPSender script will tell ZAP to add the token we acquired from the [authenticator](#authenticator) script into the `Authorization` header for every 'in scope' request, headed to the UI or API. The YML file defines what jobs are to be run as part of an automated scan. +The [data](./data) folder contains configuration and automation data files that are used to control ZAP's scan. This includes an HttpSender script and an automation YML file. The HttpSender script (`bearer-token.js`) routes requests to the correct bearer token based on hostname + path: requests to the state API host get the M2M token, requests under `/v1/provider-users/*`, `/v1/purchases/*`, and `GET /v1/compacts/{compact}/attestations/{attestationId}` get the provider token, everything else gets the staff token. The YML file defines the ZAP automation plan. # Set up -In order for the scan to be able to run successfully, the scanned environment requires some set-up: -1) The target environment backend needs to be deployed with the `"security_profile": "VULNERABLE"` environment context set in order to prevent ZAP from being locked out. The `VULNERABLE` profile weakens a number of security elements to allow for the scan including: - - Removing the rate limit from the WAF policies - - Enabling the SRP authentication flow - - Disabling Cognito Advanced Security - - Removing MFA requirements +In order for the scan to run successfully, the target environment needs some set-up: + +1. The target environment backend needs to be deployed with the `"security_profile": "VULNERABLE"` environment context set in order to prevent ZAP from being locked out. The `VULNERABLE` profile weakens a number of security elements to allow for the scan, including: + - Removing the rate limit from the WAF policies + - Enabling the SRP authentication flow (for both staff and provider pools) + - Disabling Cognito Advanced Security + - Removing MFA requirements Because of this loosened security for scanning, the `VULNERABLE` security profile cannot be used in the production environment. -2) Create a dedicated test user in the StaffUsers Cognito UserPool -3) Define the following secrets in the GitHub repository with the corresponding information from the staff user pool, client, and test user: -``` - TEST_COGNITO_USER_POOL_ID_STAFF - TEST_WEBROOT_COGNITO_CLIENT_ID_STAFF - TEST_ZAP_USERNAME_STAFF - TEST_ZAP_PASSWORD_STAFF -``` +2. **Staff Users pool** — create a dedicated test user with broad scope coverage (e.g. `aslp/admin` plus full `oh` and `ky` jurisdiction permissions). Define these GitHub secrets: + + ``` + TEST_COGNITO_USER_POOL_ID_STAFF + TEST_WEBROOT_COGNITO_CLIENT_ID_STAFF + TEST_ZAP_USERNAME_STAFF + TEST_ZAP_PASSWORD_STAFF + ``` + +3. **Provider Users pool** — create a dedicated test provider user. The user needs a backing provider record in DynamoDB with a license in a covered jurisdiction (e.g. `ky` or `oh` within ASLP) so the `/v1/provider-users/me/*` endpoints resolve. ZAP only needs handlers to execute — response codes don't matter for vulnerability scanning, so POST endpoints (`/v1/purchases/privileges`, `/v1/provider-users/me/military-affiliation`) returning 4xx on repeat runs because records already exist is fine; no cleanup is required between scans. Define these GitHub secrets: + + ``` + TEST_COGNITO_USER_POOL_ID_PROVIDER + TEST_COGNITO_CLIENT_ID_PROVIDER + TEST_ZAP_USERNAME_PROVIDER + TEST_ZAP_PASSWORD_PROVIDER + ``` + +4. **State Auth M2M pool** — provision an app client dedicated to ZAP scanning. Follow the process in [`../backend/compact-connect/app_clients/README.md`](../backend/compact-connect/app_clients/README.md), granting the minimum scopes needed to exercise the four state API endpoints: + + - `{compact}/readGeneral` + - `{state}/{compact}.write` for each covered jurisdiction + + After creation, bump the access token validity from the default 15 minutes to 60 minutes so a single token covers a scan run under the 45-minute active scan cap: + + ``` + aws cognito-idp update-user-pool-client \ + --user-pool-id \ + --client-id \ + --access-token-validity 60 \ + --token-validity-units AccessToken=minutes + ``` + + Then define these GitHub secrets: + + ``` + TEST_COGNITO_STATE_AUTH_DOMAIN (e.g. compact-connect-state-auth-test.auth.us-east-1.amazoncognito.com) + TEST_STATE_AUTH_CLIENT_ID + TEST_STATE_AUTH_CLIENT_SECRET + TEST_STATE_AUTH_SCOPES (space-separated, e.g. "aslp/readGeneral aslp/write ky/aslp.write oh/aslp.write") + ``` # Manual run -If you wish to run the ZAP scan locally, you can do so buy following these steps: -1) Install `docker` [docker](https://www.docker.com/) on your computer. -2) From inside the [authenticator](./authenticator) folder, copy `env.example` to `.env` and update the example values with the real values from the StaffUser user pool and the test user created in [Set up](#set-up). -3) From the repository root, run `./owasp-zap/manual-run.sh`. The scan will run inside a docker container and the report will be added to a `reports` folder at the repository root. +To run the ZAP scan locally: + +1. Install [docker](https://www.docker.com/). +2. From inside the [authenticator](./authenticator) folder, copy `.env.example` to `.env` and fill in whichever credential sets you have. Missing sets (e.g. you only have staff creds) are skipped with a warning — the scan still runs, but endpoints needing the missing tokens will return 401. +3. From the repository root, run `./owasp-zap/manual-scan.sh`. The scan runs inside a Docker container and the report is written to a `report/` folder at the repository root. diff --git a/owasp-zap/authenticator/.env.example b/owasp-zap/authenticator/.env.example index e5542d1cf..eb49f7745 100644 --- a/owasp-zap/authenticator/.env.example +++ b/owasp-zap/authenticator/.env.example @@ -1,4 +1,20 @@ -COGNITO_USER_POOL_ID=us-east-1_abcdefg -COGNITO_USER_POOL_CLIENT_ID=abcdefghijklmnop1234567890 -COGNITO_USERNAME=user@example.com -COGNITO_PASSWORD=123imapassword456 +# Copy to .env and fill in real values for whichever credential sets you want to scan. +# Missing sets will be skipped by manual-scan.sh with a warning. + +# Staff Users pool (most endpoints on api.test.compactconnect.org) +STAFF_COGNITO_USER_POOL_ID=us-east-1_abcdefg +STAFF_COGNITO_USER_POOL_CLIENT_ID=abcdefghijklmnop1234567890 +STAFF_COGNITO_USERNAME=staff-zap@example.com +STAFF_COGNITO_PASSWORD=123imapassword456 + +# Provider Users pool (/v1/provider-users/*, /v1/purchases/*, GET attestation-by-id) +PROVIDER_COGNITO_USER_POOL_ID=us-east-1_hijklmn +PROVIDER_COGNITO_USER_POOL_CLIENT_ID=qrstuvwxyz0987654321 +PROVIDER_COGNITO_USERNAME=provider-zap@example.com +PROVIDER_COGNITO_PASSWORD=456imapassword789 + +# State Auth M2M pool (state-api.test.compactconnect.org) +COGNITO_STATE_AUTH_DOMAIN=compact-connect-state-auth-test.auth.us-east-1.amazoncognito.com +STATE_AUTH_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx +STATE_AUTH_CLIENT_SECRET=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +STATE_AUTH_SCOPES=aslp/readGeneral aslp/write ky/aslp.write oh/aslp.write diff --git a/owasp-zap/authenticator/get-m2m-token.sh b/owasp-zap/authenticator/get-m2m-token.sh new file mode 100755 index 000000000..fc50eac13 --- /dev/null +++ b/owasp-zap/authenticator/get-m2m-token.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# Fetches a Cognito client_credentials access token for the State Auth M2M pool. +# Prints only the access_token to stdout on success. +# +# Requires: +# COGNITO_STATE_AUTH_DOMAIN — e.g. compact-connect-state-auth-test.auth.us-east-1.amazoncognito.com +# STATE_AUTH_CLIENT_ID +# STATE_AUTH_CLIENT_SECRET +# STATE_AUTH_SCOPES — space-separated list, e.g. "aslp/readGeneral ky/aslp.write" + +set -euo pipefail + +: "${COGNITO_STATE_AUTH_DOMAIN:?COGNITO_STATE_AUTH_DOMAIN is required}" +: "${STATE_AUTH_CLIENT_ID:?STATE_AUTH_CLIENT_ID is required}" +: "${STATE_AUTH_CLIENT_SECRET:?STATE_AUTH_CLIENT_SECRET is required}" +: "${STATE_AUTH_SCOPES:?STATE_AUTH_SCOPES is required}" + +response=$(curl -sS --fail-with-body \ + -X POST "https://${COGNITO_STATE_AUTH_DOMAIN}/oauth2/token" \ + -u "${STATE_AUTH_CLIENT_ID}:${STATE_AUTH_CLIENT_SECRET}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "scope=${STATE_AUTH_SCOPES}") + +token=$(jq -r '.access_token // empty' <<<"$response") +if [[ -z "$token" ]]; then + echo "Failed to obtain M2M token. Response: $response" >&2 + exit 1 +fi +printf '%s' "$token" diff --git a/owasp-zap/authenticator/main.js b/owasp-zap/authenticator/main.js index 56b0353bc..39533bbd6 100644 --- a/owasp-zap/authenticator/main.js +++ b/owasp-zap/authenticator/main.js @@ -2,21 +2,27 @@ const dotenv = require('dotenv'); const { Amplify } = require('aws-amplify'); const { signIn, fetchAuthSession } = require('@aws-amplify/auth'); -// Load environment variables dotenv.config(); -const cognitoUserPoolId = process.env.COGNITO_USER_POOL_ID; -const cognitoUserPoolClientId = process.env.COGNITO_USER_POOL_CLIENT_ID; -// Main execution -const cognitoUsername = process.env.COGNITO_USERNAME; -const cognitoPassword = process.env.COGNITO_PASSWORD; +// --mode=staff reads STAFF_COGNITO_*, --mode=provider reads PROVIDER_COGNITO_*. +const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')); +const mode = modeArg ? modeArg.split('=')[1] : ''; +if (!mode) { + console.error('Missing --mode=staff|provider'); + process.exit(1); +} +const prefix = `${mode.toUpperCase()}_`; + +const cognitoUserPoolId = process.env[`${prefix}COGNITO_USER_POOL_ID`]; +const cognitoUserPoolClientId = process.env[`${prefix}COGNITO_USER_POOL_CLIENT_ID`]; +const cognitoUsername = process.env[`${prefix}COGNITO_USERNAME`]; +const cognitoPassword = process.env[`${prefix}COGNITO_PASSWORD`]; if (!cognitoUserPoolId || !cognitoUserPoolClientId || !cognitoUsername || !cognitoPassword) { - console.error('Missing environment variables'); + console.error(`Missing environment variables for mode=${mode}`); process.exit(1); } -// Configure Amplify Amplify.configure({ Auth: { Cognito: { diff --git a/owasp-zap/data/bearer-token.js b/owasp-zap/data/bearer-token.js index 28a802e76..5c2f8ed12 100644 --- a/owasp-zap/data/bearer-token.js +++ b/owasp-zap/data/bearer-token.js @@ -1,27 +1,48 @@ /* * Adapted from https://github.com/zaproxy/community-scripts/blob/main/httpsender/AddBearerTokenHeader.js - * This script adds a bearer token to all requests in scope except the authorization request itself - * The token is retrieved from the environment variable TOKEN, which can be acquired separately from ZAP. + * + * Selects an Authorization bearer token based on the target URL. CompactConnect fronts three + * distinct Cognito pools behind the scan targets: + * - Staff pool for most endpoints on api.test.compactconnect.org + * - Provider users pool for /v1/provider-users/*, /v1/purchases/*, and the GET on + * /v1/compacts/{compact}/attestations/{attestationId} + * - State auth M2M pool for state-api.test.compactconnect.org + * + * Tokens come from env vars set by the workflow or manual-scan.sh: + * ZAP_AUTH_STAFF_TOKEN, ZAP_AUTH_PROVIDER_TOKEN, ZAP_AUTH_STATE_TOKEN */ var HttpSender = Java.type('org.parosproxy.paros.network.HttpSender'); const System = Java.type('java.lang.System'); -const token = System.getenv('ZAP_AUTH_HEADER_VALUE'); +const TOKENS = { + staff: System.getenv('ZAP_AUTH_STAFF_TOKEN'), + provider: System.getenv('ZAP_AUTH_PROVIDER_TOKEN'), + state: System.getenv('ZAP_AUTH_STATE_TOKEN'), +}; +const PROVIDER_PATH_PREFIX = /^\/v1\/(provider-users|purchases)(\/|$)/; +const PROVIDER_ATTESTATION_PATH = /^\/v1\/compacts\/[^\/]+\/attestations\/[^\/]+$/; + +function classifyRequest(host, path) { + if (host.indexOf('state-api.') === 0) return 'state'; + if (PROVIDER_PATH_PREFIX.test(path)) return 'provider'; + if (PROVIDER_ATTESTATION_PATH.test(path)) return 'provider'; + return 'staff'; +} function sendingRequest(msg, initiator, helper) { - // add Authorization header to all request in scope except the authorization request itself - if (initiator !== HttpSender.AUTHENTICATION_INITIATOR && msg.isInScope()) { - if (!token) { - print('Token not defined'); - return - } - msg.getRequestHeader().setHeader( - 'Authorization', - 'Bearer ' + token - ); + if (initiator === HttpSender.AUTHENTICATION_INITIATOR || !msg.isInScope()) return; + + const uri = msg.getRequestHeader().getURI(); + const kind = classifyRequest(String(uri.getHost()), String(uri.getPath())); + const token = TOKENS[kind]; + + if (!token) { + print('No ' + kind + ' token available for ' + uri.toString()); + return; } + msg.getRequestHeader().setHeader('Authorization', 'Bearer ' + token); } function responseReceived(msg, initiator, helper) { diff --git a/owasp-zap/data/test-automation.yml b/owasp-zap/data/test-automation.yml index fe634fdb5..798cf1eae 100644 --- a/owasp-zap/data/test-automation.yml +++ b/owasp-zap/data/test-automation.yml @@ -4,9 +4,11 @@ env: urls: - https://api.test.compactconnect.org - https://search.test.compactconnect.org + - https://state-api.test.compactconnect.org includePaths: - https://api.test.compactconnect.org.* - https://search.test.compactconnect.org.* + - https://state-api.test.compactconnect.org.* authentication: verification: method: response @@ -51,6 +53,13 @@ jobs: targetUrl: "https://search.test.compactconnect.org" context: test user: "" +- type: openapi + parameters: + apiFile: /zap/wrk/backend/compact-connect/docs/api-specification/latest-oas30-zap.json + apiUrl: "" + targetUrl: "https://state-api.test.compactconnect.org" + context: test + user: "" # Suppress known false positives - type: alertFilter alertFilters: diff --git a/owasp-zap/manual-scan.sh b/owasp-zap/manual-scan.sh index c76141d27..b6bbcc235 100755 --- a/owasp-zap/manual-scan.sh +++ b/owasp-zap/manual-scan.sh @@ -1,14 +1,59 @@ -#/usr/bin/env bash +#!/usr/bin/env bash +# +# Runs the ZAP automation scan in Docker against the test environment. +# Pulls bearer tokens for each credential set defined in owasp-zap/authenticator/.env +# (STAFF_*, PROVIDER_*, STATE_*). Missing credential sets are skipped with a warning — +# the scan will still run, but endpoints needing the missing token will return 401. set -e -# Log in as a user to get a token -TOKEN="$(cd owasp-zap/authenticator; node main.js | jq -r '.accessToken')" +cd "$(dirname "$0")/.." -[[ -z "$TOKEN" ]] && echo "Failed to get token" && exit 1 +authenticator_dir="owasp-zap/authenticator" +env_file="$authenticator_dir/.env" + +if [[ ! -f "$env_file" ]]; then + echo "Missing $env_file. Copy .env.example and fill in credentials." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +. "$env_file" +set +a + +fetch_user_token() { + local mode="$1" + local prefix="$2" + local pool_id_var="${prefix}_COGNITO_USER_POOL_ID" + if [[ -z "${!pool_id_var:-}" ]]; then + echo "Skipping $mode token: ${prefix}_COGNITO_* vars not set in $env_file" >&2 + return 1 + fi + (cd "$authenticator_dir" && node main.js --mode="$mode") | jq -r '.accessToken' +} + +fetch_m2m_token() { + if [[ -z "${COGNITO_STATE_AUTH_DOMAIN:-}" ]]; then + echo "Skipping state M2M token: COGNITO_STATE_AUTH_DOMAIN not set in $env_file" >&2 + return 1 + fi + (cd "$authenticator_dir" && ./get-m2m-token.sh) +} + +STAFF_TOKEN=$(fetch_user_token staff STAFF || true) +PROVIDER_TOKEN=$(fetch_user_token provider PROVIDER || true) +STATE_TOKEN=$(fetch_m2m_token || true) + +if [[ -z "$STAFF_TOKEN$PROVIDER_TOKEN$STATE_TOKEN" ]]; then + echo "No tokens obtained; aborting." >&2 + exit 1 +fi docker run \ -v "$(pwd):/zap/wrk:rw" \ - -e ZAP_AUTH_HEADER_VALUE="$TOKEN" \ + -e ZAP_AUTH_STAFF_TOKEN="$STAFF_TOKEN" \ + -e ZAP_AUTH_PROVIDER_TOKEN="$PROVIDER_TOKEN" \ + -e ZAP_AUTH_STATE_TOKEN="$STATE_TOKEN" \ -t zaproxy/zap-stable \ zap.sh -cmd \ -autorun /zap/wrk/owasp-zap/data/test-automation.yml From e235d322b89cea0aeda7ce8e3d061232b78a02db Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 12:42:32 -0400 Subject: [PATCH 02/12] Rename state-auth ZAP secrets with _ZAP_ prefix Renames TEST_STATE_AUTH_CLIENT_ID/_SECRET/_SCOPES to TEST_ZAP_STATE_AUTH_* for clarity (these are ZAP-specific, not general test credentials). Also pins the staff userId in enrich-oas-for-zap.py to a dedicated test user to avoid ZAP mutating the account it's authenticating with. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/zap-scan-test.yml | 6 +++--- owasp-zap/README.md | 6 +++--- owasp-zap/enrich-oas-for-zap.py | 4 +++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/zap-scan-test.yml b/.github/workflows/zap-scan-test.yml index a9d44ed70..1610fe4ba 100644 --- a/.github/workflows/zap-scan-test.yml +++ b/.github/workflows/zap-scan-test.yml @@ -74,9 +74,9 @@ jobs: - name: Authenticate State API M2M Client env: COGNITO_STATE_AUTH_DOMAIN: ${{ secrets.TEST_COGNITO_STATE_AUTH_DOMAIN }} - STATE_AUTH_CLIENT_ID: ${{ secrets.TEST_STATE_AUTH_CLIENT_ID }} - STATE_AUTH_CLIENT_SECRET: ${{ secrets.TEST_STATE_AUTH_CLIENT_SECRET }} - STATE_AUTH_SCOPES: ${{ secrets.TEST_STATE_AUTH_SCOPES }} + STATE_AUTH_CLIENT_ID: ${{ secrets.TEST_ZAP_STATE_AUTH_CLIENT_ID }} + STATE_AUTH_CLIENT_SECRET: ${{ secrets.TEST_ZAP_STATE_AUTH_CLIENT_SECRET }} + STATE_AUTH_SCOPES: ${{ secrets.TEST_ZAP_STATE_AUTH_SCOPES }} run: | token=$(./get-m2m-token.sh) if [[ -z "$token" ]]; then diff --git a/owasp-zap/README.md b/owasp-zap/README.md index 2a8986f78..c1fde116b 100644 --- a/owasp-zap/README.md +++ b/owasp-zap/README.md @@ -62,9 +62,9 @@ In order for the scan to run successfully, the target environment needs some set ``` TEST_COGNITO_STATE_AUTH_DOMAIN (e.g. compact-connect-state-auth-test.auth.us-east-1.amazoncognito.com) - TEST_STATE_AUTH_CLIENT_ID - TEST_STATE_AUTH_CLIENT_SECRET - TEST_STATE_AUTH_SCOPES (space-separated, e.g. "aslp/readGeneral aslp/write ky/aslp.write oh/aslp.write") + TEST_ZAP_STATE_AUTH_CLIENT_ID + TEST_ZAP_STATE_AUTH_CLIENT_SECRET + TEST_ZAP_STATE_AUTH_SCOPES (space-separated, e.g. "aslp/readGeneral ky/aslp.write oh/aslp.write") ``` # Manual run diff --git a/owasp-zap/enrich-oas-for-zap.py b/owasp-zap/enrich-oas-for-zap.py index 3df727c3c..fe068bf7d 100644 --- a/owasp-zap/enrich-oas-for-zap.py +++ b/owasp-zap/enrich-oas-for-zap.py @@ -18,7 +18,9 @@ "jurisdiction": "oh", "licenseType": "audiologist", "providerId": "33f813a7-9526-4bba-95d6-570fcc2a5a12", - "userId": "740864a8-8091-7097-3bb4-d96fb1619a15", + + # Test user specifically created in test environment for modification by the ZAP scan + "userId": "3478a468-10f1-7011-b884-a2b4987561b4", "attestationId": "jurisprudence-confirmation", "encumbranceId": "c8083de6-19a7-4e9c-8411-09e883fbc8ff", "flagId": "00000000-0000-4000-8000-000000000000", From d7c1e27138db738f15121703628c121f72630ed8 Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 12:56:00 -0400 Subject: [PATCH 03/12] Simplify ZAP state-auth client provisioning docs Replace the post-creation update-user-pool-client patch (which is a full-replacement API and easy to get wrong) with a one-shot temporary edit to BASE_CLIENT_CONFIG in create_app_client.py. Also clarifies the full scope list to enter at the prompts so future rotations don't miss aslp/readGeneral. Co-Authored-By: Claude Opus 4.7 (1M context) --- owasp-zap/README.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/owasp-zap/README.md b/owasp-zap/README.md index c1fde116b..8dd0592ce 100644 --- a/owasp-zap/README.md +++ b/owasp-zap/README.md @@ -43,20 +43,23 @@ In order for the scan to run successfully, the target environment needs some set TEST_ZAP_PASSWORD_PROVIDER ``` -4. **State Auth M2M pool** — provision an app client dedicated to ZAP scanning. Follow the process in [`../backend/compact-connect/app_clients/README.md`](../backend/compact-connect/app_clients/README.md), granting the minimum scopes needed to exercise the four state API endpoints: +4. **State Auth M2M pool** — provision an app client dedicated to ZAP scanning. This client needs two things that differ from a standard state onboarding client: - - `{compact}/readGeneral` - - `{state}/{compact}.write` for each covered jurisdiction + - A 60-minute access token validity (vs. the 15-minute default) so one token covers a scan run under the 45-minute active scan cap. + - Scopes to exercise all four state API endpoints: `{compact}/readGeneral` plus `{state}/{compact}.write` for each covered jurisdiction. - After creation, bump the access token validity from the default 15 minutes to 60 minutes so a single token covers a scan run under the 45-minute active scan cap: + Rather than creating with defaults and patching after (`update-user-pool-client` is a full-replacement API — any attribute omitted is reset to its default, which is easy to get wrong), temporarily bump the validity in the creation script: - ``` - aws cognito-idp update-user-pool-client \ - --user-pool-id \ - --client-id \ - --access-token-validity 60 \ - --token-validity-units AccessToken=minutes - ``` + 1. In `backend/compact-connect/app_clients/bin/create_app_client.py`, change `BASE_CLIENT_CONFIG['AccessTokenValidity']` from `15` to `60`. **Do not commit this change** — it's a one-shot override for this ZAP client. + 2. Run the script against the test StateAuthUsers pool: + + ``` + python3 backend/compact-connect/app_clients/bin/create_app_client.py -u + ``` + + At the prompts: client name `owasp-zap-v1` (increment the version on rotation), compact `aslp`, state `ky`, additional scopes `aslp/readGeneral,oh/aslp.write`. Capture the `clientId` / `clientSecret` from the output. + 3. Revert the `AccessTokenValidity` change in `create_app_client.py`. + 4. Verify the client with `aws cognito-idp describe-user-pool-client` — you should see `AccessTokenValidity: 60` and `AllowedOAuthScopes` containing `aslp/readGeneral`, `ky/aslp.write`, `oh/aslp.write`. Then define these GitHub secrets: From b4176424f645ed3c7a86c6577cf86fccc60c550e Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 12:56:28 -0400 Subject: [PATCH 04/12] TEMP: trigger ZAP scan on PR events Lets us observe the scan end-to-end from GitHub's UI before merge. Revert this commit before the branch lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/zap-scan-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/zap-scan-test.yml b/.github/workflows/zap-scan-test.yml index 1610fe4ba..0a2fce4c1 100644 --- a/.github/workflows/zap-scan-test.yml +++ b/.github/workflows/zap-scan-test.yml @@ -1,6 +1,9 @@ name: ZAP-Scan-Test on: + # TEMP: trigger on PR pushes so we can validate the scan end-to-end. Revert before merge. + pull_request: + # Weekly scheduled scan of the deployed test environment schedule: - cron: '0 6 * * 1' # Every Monday at 06:00 UTC From 105edda330d9652260ebd7c9cc2466052393f20a Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 13:51:27 -0400 Subject: [PATCH 05/12] Pass multi-auth tokens to ZAP container via docker_env_vars Bumps zaproxy/action-af from v0.1.0 to v0.2.0, which adds a docker_env_vars input for forwarding env vars into the ZAP docker container. Without this, the three new ZAP_AUTH_*_TOKEN vars set in $GITHUB_ENV never reached the container, so bearer-token.js got null from System.getenv and every authenticated request returned 401. The staff-only predecessor worked by piggybacking on ZAP_AUTH_HEADER_VALUE, which action-af hardcoded to forward; the multi-auth rewrite renamed the vars without a corresponding passthrough mechanism. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/zap-scan-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/zap-scan-test.yml b/.github/workflows/zap-scan-test.yml index 0a2fce4c1..e2a2b7b7f 100644 --- a/.github/workflows/zap-scan-test.yml +++ b/.github/workflows/zap-scan-test.yml @@ -103,9 +103,13 @@ jobs: backend/compact-connect/docs/api-specification/latest-oas30-zap.json - name: ZAP Scan - uses: zaproxy/action-af@v0.1.0 + uses: zaproxy/action-af@v0.2.0 with: plan: 'owasp-zap/data/test-automation.yml' + docker_env_vars: | + ZAP_AUTH_STAFF_TOKEN + ZAP_AUTH_PROVIDER_TOKEN + ZAP_AUTH_STATE_TOKEN - name: Upload Report if: always() From 615c41a76d47226361fe23336b79735054bde342 Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 14:16:36 -0400 Subject: [PATCH 06/12] Use ID token for provider ZAP auth Provider Lambda handlers extract claims (email/cognito:username) that only exist on the ID token; the access token was getting rejected by the authorizer/handlers, so every authenticated provider-endpoint request came back 401. Matches the pattern in backend/compact-connect/tests/smoke/smoke_common.py:174. Staff endpoints continue to use the access token. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/zap-scan-test.yml | 4 +++- owasp-zap/manual-scan.sh | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/zap-scan-test.yml b/.github/workflows/zap-scan-test.yml index e2a2b7b7f..7a22058ec 100644 --- a/.github/workflows/zap-scan-test.yml +++ b/.github/workflows/zap-scan-test.yml @@ -65,7 +65,9 @@ jobs: PROVIDER_COGNITO_USERNAME: ${{ secrets.TEST_ZAP_USERNAME_PROVIDER }} PROVIDER_COGNITO_PASSWORD: ${{ secrets.TEST_ZAP_PASSWORD_PROVIDER }} run: | - token=$(node main.js --mode=provider | jq -r '.accessToken') + # Provider API handlers read claims (email/username) from the ID token, + # not the access token (see backend/compact-connect/tests/smoke/smoke_common.py:174). + token=$(node main.js --mode=provider | jq -r '.idToken') if [[ -z "$token" || "$token" == "null" ]]; then echo "::error::Failed to obtain provider authentication token" exit 1 diff --git a/owasp-zap/manual-scan.sh b/owasp-zap/manual-scan.sh index b6bbcc235..bb8ded111 100755 --- a/owasp-zap/manual-scan.sh +++ b/owasp-zap/manual-scan.sh @@ -29,7 +29,13 @@ fetch_user_token() { echo "Skipping $mode token: ${prefix}_COGNITO_* vars not set in $env_file" >&2 return 1 fi - (cd "$authenticator_dir" && node main.js --mode="$mode") | jq -r '.accessToken' + # Provider API handlers require the ID token (claims like email/username); + # staff API accepts the access token. + local token_field='.accessToken' + if [[ "$mode" == 'provider' ]]; then + token_field='.idToken' + fi + (cd "$authenticator_dir" && node main.js --mode="$mode") | jq -r "$token_field" } fetch_m2m_token() { From 8373eb6211ce76a3354843eaaf02e7c95fb0ebdc Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 15:01:23 -0400 Subject: [PATCH 07/12] Add body examples for three providers/query endpoints in ZAP enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZAP's active scan was hitting these read-only POST /providers/query endpoints (staff, public, state) with invalid bodies and getting 400s on the baseline request — so fuzzing never saw the stored-search path. Adding a minimal valid example per endpoint gives ZAP a 2xx baseline to fuzz around, exercising the query/pagination handling. The three endpoints account for ~2.5k of the 400s in the last run. Only read-only endpoints are listed; mutation endpoints like POST /licenses would flood the test DB with junk records. Co-Authored-By: Claude Opus 4.7 (1M context) --- owasp-zap/enrich-oas-for-zap.py | 52 ++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/owasp-zap/enrich-oas-for-zap.py b/owasp-zap/enrich-oas-for-zap.py index fe068bf7d..3eed563b0 100644 --- a/owasp-zap/enrich-oas-for-zap.py +++ b/owasp-zap/enrich-oas-for-zap.py @@ -28,6 +28,30 @@ } +# Valid baseline request bodies for selected endpoints, keyed by (method, path). +# Only read-only endpoints are listed here — mutation endpoints would flood the +# test DB with junk records. ZAP uses each example as the baseline for active +# scanning, then fuzzes variants around it. +BODY_EXAMPLES = { + # Read-only provider search (staff + public share one schema) + ("post", "/v1/compacts/{compact}/providers/query"): { + "query": {"jurisdiction": "oh"}, + "pagination": {"pageSize": 10}, + }, + ("post", "/v1/public/compacts/{compact}/providers/query"): { + "query": {"jurisdiction": "oh"}, + "pagination": {"pageSize": 10}, + }, + # State API provider search — different schema, requires a date-time window + ("post", "/v1/compacts/{compact}/jurisdictions/{jurisdiction}/providers/query"): { + "query": { + "startDateTime": "2024-01-01T00:00:00Z", + "endDateTime": "2025-01-01T00:00:00Z", + }, + }, +} + + # HTTP methods to strip from the spec before ZAP ingests it. # DELETE is excluded to prevent ZAP from deleting staff users during scans. EXCLUDED_METHODS = {"delete"} @@ -51,6 +75,16 @@ def enrich_spec(spec): if name in PARAM_EXAMPLES: param["example"] = PARAM_EXAMPLES[name] + body_example = BODY_EXAMPLES.get((method, path)) + if body_example is not None: + json_content = ( + operation.get("requestBody", {}) + .get("content", {}) + .get("application/json") + ) + if json_content is not None and "example" not in json_content: + json_content["example"] = body_example + # Fix arrays missing 'items' — CDK-generated specs sometimes omit this, # which is technically invalid OpenAPI and causes ZAP's parser to fail. for name, schema in spec.get("components", {}).get("schemas", {}).items(): @@ -85,16 +119,26 @@ def main(): with open(output_path, "w") as f: json.dump(spec, f, indent=2) - # Count how many parameters were enriched - count = 0 + # Count how many parameters and bodies were enriched + param_count = 0 + body_count = 0 for path, methods in spec.get("paths", {}).items(): for method, operation in methods.items(): if not isinstance(operation, dict): continue for param in operation.get("parameters", []): if param.get("in") == "path" and "example" in param: - count += 1 - print(f"Enriched {count} path parameters with example values") + param_count += 1 + json_content = ( + operation.get("requestBody", {}) + .get("content", {}) + .get("application/json", {}) + ) + if "example" in json_content: + body_count += 1 + print( + f"Enriched {param_count} path parameters and {body_count} request bodies" + ) if __name__ == "__main__": From 2b7df02ea909eb831edcf323abe63cedbe710d6b Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 15:48:58 -0400 Subject: [PATCH 08/12] Narrow state /providers/query example window to 7 days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler rejects date ranges wider than 7 days (lambdas/python/common/cc_common/data_model/schema/provider/api.py:331-332). My initial 2024-01-01 → 2025-01-01 example was 365 days, producing 400 on every baseline request. A 7-day window in April 2026 stays inside the limit so ZAP gets a real 2xx baseline to fuzz around. Co-Authored-By: Claude Opus 4.7 (1M context) --- owasp-zap/enrich-oas-for-zap.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/owasp-zap/enrich-oas-for-zap.py b/owasp-zap/enrich-oas-for-zap.py index 3eed563b0..d61c82760 100644 --- a/owasp-zap/enrich-oas-for-zap.py +++ b/owasp-zap/enrich-oas-for-zap.py @@ -42,11 +42,13 @@ "query": {"jurisdiction": "oh"}, "pagination": {"pageSize": 10}, }, - # State API provider search — different schema, requires a date-time window + # State API provider search — different schema, requires a date-time window. + # The handler caps the window at 7 days + # (lambdas/python/common/cc_common/data_model/schema/provider/api.py:331-332). ("post", "/v1/compacts/{compact}/jurisdictions/{jurisdiction}/providers/query"): { "query": { - "startDateTime": "2024-01-01T00:00:00Z", - "endDateTime": "2025-01-01T00:00:00Z", + "startDateTime": "2026-04-15T00:00:00Z", + "endDateTime": "2026-04-22T00:00:00Z", }, }, } From 848d5d537e43dd247ea590f9ec8e20f48b7a23a0 Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 17:27:51 -0400 Subject: [PATCH 09/12] TEMP: log state /providers/query 400 req/resp for diagnosis Revert before merge. Investigating why the body example we added for state API isn't producing 2xx baselines like the public/staff equivalents do. Co-Authored-By: Claude Opus 4.7 (1M context) --- owasp-zap/data/bearer-token.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/owasp-zap/data/bearer-token.js b/owasp-zap/data/bearer-token.js index 5c2f8ed12..0e4f7ede7 100644 --- a/owasp-zap/data/bearer-token.js +++ b/owasp-zap/data/bearer-token.js @@ -47,11 +47,20 @@ function sendingRequest(msg, initiator, helper) { function responseReceived(msg, initiator, helper) { const statusCode = msg.getResponseHeader().getStatusCode(); + const uri = msg.getRequestHeader().getURI(); print( statusCode, msg.getRequestHeader().getMethod(), - msg.getRequestHeader().getURI().toString() + uri.toString() ); + // TEMP: diagnose why state /providers/query 400s despite the body example + const path = String(uri.getPath()); + if (statusCode === 400 && String(uri.getHost()).indexOf('state-api.') === 0 && path.indexOf('/providers/query') !== -1) { + const body = String(msg.getRequestBody().toString()).substring(0, 400); + const resp = String(msg.getResponseBody().toString()).substring(0, 400); + print('[state-query-400] REQ:', body); + print('[state-query-400] RESP:', resp); + } // To debug auth issues, uncomment this for a hint // if (statusCode === 401 || statusCode == 403 ) { // print('Request header:', msg.getRequestHeader().getHeader('Authorization').substring(0, 16)); From 6b42fe906b6d0e39a960f500cdb6f2a3248b2b90 Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 17:51:54 -0400 Subject: [PATCH 10/12] TEMP: log all clean-URL state /providers/query req/resp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refining earlier debug — previous filter caught path-traversal-fuzzed URLs that CloudFront rejected with HTML 400s. Narrowing to the exact clean URL with Content-Type + body + response, to see whether ZAP ever sends the baseline example at all. Revert before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- owasp-zap/data/bearer-token.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/owasp-zap/data/bearer-token.js b/owasp-zap/data/bearer-token.js index 0e4f7ede7..acf2c1697 100644 --- a/owasp-zap/data/bearer-token.js +++ b/owasp-zap/data/bearer-token.js @@ -53,13 +53,13 @@ function responseReceived(msg, initiator, helper) { msg.getRequestHeader().getMethod(), uri.toString() ); - // TEMP: diagnose why state /providers/query 400s despite the body example + // TEMP: diagnose state /providers/query baseline — only the clean URL const path = String(uri.getPath()); - if (statusCode === 400 && String(uri.getHost()).indexOf('state-api.') === 0 && path.indexOf('/providers/query') !== -1) { - const body = String(msg.getRequestBody().toString()).substring(0, 400); - const resp = String(msg.getResponseBody().toString()).substring(0, 400); - print('[state-query-400] REQ:', body); - print('[state-query-400] RESP:', resp); + if (String(uri.getHost()).indexOf('state-api.') === 0 && path === '/v1/compacts/aslp/jurisdictions/oh/providers/query') { + const ctype = msg.getRequestHeader().getHeader('Content-Type') || '(none)'; + const body = String(msg.getRequestBody().toString()).replace(/\n/g, ' ').substring(0, 300); + const resp = String(msg.getResponseBody().toString()).replace(/\n/g, ' ').substring(0, 300); + print('[state-query ' + statusCode + '] CT:' + ctype + ' REQ:' + body + ' RESP:' + resp); } // To debug auth issues, uncomment this for a hint // if (statusCode === 401 || statusCode == 403 ) { From 76b76def6473fe3038c62d66359b56c388b3de23 Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 18:29:44 -0400 Subject: [PATCH 11/12] Defensive guard for body-example insertion Skip injecting example when application/json already has examples (plural) or isn't a mapping. Addresses CodeRabbit review feedback. Co-Authored-By: Claude Opus 4.7 (1M context) --- owasp-zap/enrich-oas-for-zap.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/owasp-zap/enrich-oas-for-zap.py b/owasp-zap/enrich-oas-for-zap.py index d61c82760..0f3f3c264 100644 --- a/owasp-zap/enrich-oas-for-zap.py +++ b/owasp-zap/enrich-oas-for-zap.py @@ -84,7 +84,11 @@ def enrich_spec(spec): .get("content", {}) .get("application/json") ) - if json_content is not None and "example" not in json_content: + if ( + isinstance(json_content, dict) + and "example" not in json_content + and "examples" not in json_content + ): json_content["example"] = body_example # Fix arrays missing 'items' — CDK-generated specs sometimes omit this, From dd41ce59008705f4b7d2335ec954d4b10c71b02e Mon Sep 17 00:00:00 2001 From: Joshua Kravitz Date: Wed, 22 Apr 2026 18:34:21 -0400 Subject: [PATCH 12/12] Add ECDSA signature auth for ZAP state-api scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State-api /providers/query and /providers/{id} require an ECDSA-SHA256 signature (X-Key-Id + X-Signature headers) in addition to the M2M bearer token. Without it they 401 at the signature-validation layer with "Missing required X-Key-Id header", leaving ZAP unable to probe the PII-heavy handler code paths these endpoints expose. Adds request signing in bearer-token.js: - Loads a PKCS#8 PEM from ZAP_STATE_SIGNATURE_PRIVATE_KEY - For every state-api request, attaches X-Algorithm / X-Timestamp / X-Nonce / X-Key-Id / X-Signature per the canonical string format in backend/compact-connect/docs/client_signature_auth.md - Signing is skipped when the env vars aren't set, so existing test environments continue to work (with the same signature-gated coverage gap they had before). Wires the two new secrets through the workflow's action-af step via docker_env_vars, mirrors the same env vars in manual-scan.sh, and documents the full key-provisioning flow (openssl keypair → manage_signature_keys.py registration → GitHub secret) as a new step 5 in owasp-zap/README.md. Also removes the TEMP debug logging that was added to diagnose why state-api /providers/query was 401ing; this change is the fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/zap-scan-test.yml | 7 ++ owasp-zap/README.md | 40 +++++++++- owasp-zap/authenticator/.env.example | 10 ++- owasp-zap/data/bearer-token.js | 108 ++++++++++++++++++++++++--- owasp-zap/manual-scan.sh | 2 + 5 files changed, 152 insertions(+), 15 deletions(-) diff --git a/.github/workflows/zap-scan-test.yml b/.github/workflows/zap-scan-test.yml index 7a22058ec..24bf1cfb0 100644 --- a/.github/workflows/zap-scan-test.yml +++ b/.github/workflows/zap-scan-test.yml @@ -106,12 +106,19 @@ jobs: - name: ZAP Scan uses: zaproxy/action-af@v0.2.0 + env: + # State API requires ECDSA signatures on top of the M2M bearer token. + # See backend/compact-connect/docs/client_signature_auth.md. + ZAP_STATE_SIGNATURE_PRIVATE_KEY: ${{ secrets.TEST_ZAP_STATE_SIGNATURE_PRIVATE_KEY }} + ZAP_STATE_SIGNATURE_KEY_ID: ${{ secrets.TEST_ZAP_STATE_SIGNATURE_KEY_ID }} with: plan: 'owasp-zap/data/test-automation.yml' docker_env_vars: | ZAP_AUTH_STAFF_TOKEN ZAP_AUTH_PROVIDER_TOKEN ZAP_AUTH_STATE_TOKEN + ZAP_STATE_SIGNATURE_PRIVATE_KEY + ZAP_STATE_SIGNATURE_KEY_ID - name: Upload Report if: always() diff --git a/owasp-zap/README.md b/owasp-zap/README.md index 8dd0592ce..6d2335d8f 100644 --- a/owasp-zap/README.md +++ b/owasp-zap/README.md @@ -11,7 +11,7 @@ The [authenticator](./authenticator) directory contains helpers for obtaining be # Data -The [data](./data) folder contains configuration and automation data files that are used to control ZAP's scan. This includes an HttpSender script and an automation YML file. The HttpSender script (`bearer-token.js`) routes requests to the correct bearer token based on hostname + path: requests to the state API host get the M2M token, requests under `/v1/provider-users/*`, `/v1/purchases/*`, and `GET /v1/compacts/{compact}/attestations/{attestationId}` get the provider token, everything else gets the staff token. The YML file defines the ZAP automation plan. +The [data](./data) folder contains configuration and automation data files that are used to control ZAP's scan. This includes an HttpSender script and an automation YML file. The HttpSender script (`bearer-token.js`) routes requests to the correct bearer token based on hostname + path: requests to the state API host get the M2M token, requests under `/v1/provider-users/*`, `/v1/purchases/*`, and `GET /v1/compacts/{compact}/attestations/{attestationId}` get the provider token, everything else gets the staff token. State API requests additionally get ECDSA-SHA256 request signatures (`X-Algorithm`, `X-Timestamp`, `X-Nonce`, `X-Key-Id`, `X-Signature`) computed per-request against the signing private key — see [client_signature_auth.md](../backend/compact-connect/docs/client_signature_auth.md). The YML file defines the ZAP automation plan. # Set up @@ -70,10 +70,46 @@ In order for the scan to run successfully, the target environment needs some set TEST_ZAP_STATE_AUTH_SCOPES (space-separated, e.g. "aslp/readGeneral ky/aslp.write oh/aslp.write") ``` +5. **State API request signing (ECDSA)** — state-api reads of PII-heavy data (`/providers/query`, `/providers/{id}`) require an ECDSA signature on top of the M2M bearer token per [`client_signature_auth.md`](../backend/compact-connect/docs/client_signature_auth.md). Without it, those endpoints return `401 "Missing required X-Key-Id header"` and ZAP can only probe the authorizer/WAF layer. + + We scan with signatures enabled to simulate the full insider-threat model (an attacker with access to a state IT org's credentials and signing key). Once configured, this also turns on signature enforcement for *optional*-signature endpoints like `POST /licenses`. + + Provisioning steps: + + 1. **Generate an ECDSA P-256 keypair in PKCS#8 PEM format** (Java loads PKCS#8 natively; OpenSSL's default `ecparam` output is SEC1, which requires an extra conversion step): + + ```bash + openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out zap_test_private_key.pem + openssl pkey -in zap_test_private_key.pem -pubout -out zap_test_public_key.pub + ``` + + 2. **Register the public key** for each (compact, jurisdiction) that the ZAP client will probe. With the defaults used above (`aslp`, `ky`, `oh`), that's two registrations. Rename `zap_test_public_key.pub` to `.pub` first because the script infers the key ID from the filename: + + ```bash + cp zap_test_public_key.pub zap-test-v1.pub + python3 backend/compact-connect/app_clients/bin/manage_signature_keys.py create \ + -t + # When prompted: compact=aslp, state=ky, key id=zap-test-v1 + # Repeat the create step for state=oh + ``` + + 3. **Define these GitHub secrets**: + + ``` + TEST_ZAP_STATE_SIGNATURE_KEY_ID (e.g. zap-test-v1) + TEST_ZAP_STATE_SIGNATURE_PRIVATE_KEY (paste the full PKCS#8 PEM contents, including the BEGIN/END lines) + ``` + + GitHub Actions secrets support multi-line values; paste the PEM as-is. + + 4. **Clean up the private key file from your local disk** once it's stored in GitHub secrets. Do not commit it. + + Rotation: generate a new keypair with a new key ID (`zap-test-v2`), register it alongside the old one, update the secrets, then run `manage_signature_keys.py delete` on the old key. Signature validation supports multiple active keys during rollover. + # Manual run To run the ZAP scan locally: 1. Install [docker](https://www.docker.com/). -2. From inside the [authenticator](./authenticator) folder, copy `.env.example` to `.env` and fill in whichever credential sets you have. Missing sets (e.g. you only have staff creds) are skipped with a warning — the scan still runs, but endpoints needing the missing tokens will return 401. +2. From inside the [authenticator](./authenticator) folder, copy `.env.example` to `.env` and fill in whichever credential sets you have. Missing sets (e.g. you only have staff creds) are skipped with a warning — the scan still runs, but endpoints needing the missing tokens will return 401. To also cover signature-gated state endpoints, set `STATE_SIGNATURE_KEY_ID` and paste your PKCS#8 PEM into `STATE_SIGNATURE_PRIVATE_KEY` (multi-line quoted value — see `.env.example`). 3. From the repository root, run `./owasp-zap/manual-scan.sh`. The scan runs inside a Docker container and the report is written to a `report/` folder at the repository root. diff --git a/owasp-zap/authenticator/.env.example b/owasp-zap/authenticator/.env.example index eb49f7745..5b7084474 100644 --- a/owasp-zap/authenticator/.env.example +++ b/owasp-zap/authenticator/.env.example @@ -17,4 +17,12 @@ PROVIDER_COGNITO_PASSWORD=456imapassword789 COGNITO_STATE_AUTH_DOMAIN=compact-connect-state-auth-test.auth.us-east-1.amazoncognito.com STATE_AUTH_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx STATE_AUTH_CLIENT_SECRET=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy -STATE_AUTH_SCOPES=aslp/readGeneral aslp/write ky/aslp.write oh/aslp.write +STATE_AUTH_SCOPES=aslp/readGeneral ky/aslp.write oh/aslp.write + +# State API ECDSA signature auth (optional — required for /providers/query and /providers/{id} +# coverage). See owasp-zap/README.md for key generation and registration steps. +# The private key must be in PKCS#8 PEM format. +STATE_SIGNATURE_KEY_ID=zap-test-v1 +STATE_SIGNATURE_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +MIG...replace with actual key contents... +-----END PRIVATE KEY-----" diff --git a/owasp-zap/data/bearer-token.js b/owasp-zap/data/bearer-token.js index acf2c1697..f0ec23a8c 100644 --- a/owasp-zap/data/bearer-token.js +++ b/owasp-zap/data/bearer-token.js @@ -8,12 +8,27 @@ * /v1/compacts/{compact}/attestations/{attestationId} * - State auth M2M pool for state-api.test.compactconnect.org * + * State-api endpoints additionally require ECDSA-SHA256 request signatures on top of the + * bearer token. When ZAP_STATE_SIGNATURE_PRIVATE_KEY and ZAP_STATE_SIGNATURE_KEY_ID are set, + * every state-api request is signed per backend/compact-connect/docs/client_signature_auth.md; + * otherwise state-api requests will 401 with "Missing required X-Key-Id header". + * * Tokens come from env vars set by the workflow or manual-scan.sh: - * ZAP_AUTH_STAFF_TOKEN, ZAP_AUTH_PROVIDER_TOKEN, ZAP_AUTH_STATE_TOKEN + * ZAP_AUTH_STAFF_TOKEN, ZAP_AUTH_PROVIDER_TOKEN, ZAP_AUTH_STATE_TOKEN, + * ZAP_STATE_SIGNATURE_PRIVATE_KEY (PKCS#8 PEM), ZAP_STATE_SIGNATURE_KEY_ID */ var HttpSender = Java.type('org.parosproxy.paros.network.HttpSender'); const System = Java.type('java.lang.System'); +const Signature = Java.type('java.security.Signature'); +const KeyFactory = Java.type('java.security.KeyFactory'); +const PKCS8EncodedKeySpec = Java.type('java.security.spec.PKCS8EncodedKeySpec'); +const Base64 = Java.type('java.util.Base64'); +const UUID = Java.type('java.util.UUID'); +const Instant = Java.type('java.time.Instant'); +const ChronoUnit = Java.type('java.time.temporal.ChronoUnit'); +const StandardCharsets = Java.type('java.nio.charset.StandardCharsets'); +const URLEncoder = Java.type('java.net.URLEncoder'); const TOKENS = { staff: System.getenv('ZAP_AUTH_STAFF_TOKEN'), @@ -21,6 +36,9 @@ const TOKENS = { state: System.getenv('ZAP_AUTH_STATE_TOKEN'), }; +const SIGNATURE_KEY_ID = System.getenv('ZAP_STATE_SIGNATURE_KEY_ID'); +const SIGNATURE_PRIVATE_KEY = loadSignaturePrivateKey(System.getenv('ZAP_STATE_SIGNATURE_PRIVATE_KEY')); + const PROVIDER_PATH_PREFIX = /^\/v1\/(provider-users|purchases)(\/|$)/; const PROVIDER_ATTESTATION_PATH = /^\/v1\/compacts\/[^\/]+\/attestations\/[^\/]+$/; @@ -31,11 +49,73 @@ function classifyRequest(host, path) { return 'staff'; } +function loadSignaturePrivateKey(pem) { + if (!pem) return null; + const body = String(pem) + .replace(/-----BEGIN [^-]+-----/g, '') + .replace(/-----END [^-]+-----/g, '') + .replace(/\s+/g, ''); + try { + const keyBytes = Base64.getDecoder().decode(body); + const spec = new PKCS8EncodedKeySpec(keyBytes); + return KeyFactory.getInstance('EC').generatePrivate(spec); + } catch (e) { + print('Failed to parse ZAP_STATE_SIGNATURE_PRIVATE_KEY (expected PKCS#8 PEM): ' + e); + return null; + } +} + +function rfc3986Encode(s) { + return String(URLEncoder.encode(s, 'UTF-8')) + .replace(/\+/g, '%20') + .replace(/\*/g, '%2A') + .replace(/%7E/g, '~'); +} + +function canonicalQuery(rawQuery) { + if (!rawQuery) return ''; + const pairs = []; + const items = String(rawQuery).split('&'); + for (let i = 0; i < items.length; i++) { + if (!items[i]) continue; + const eq = items[i].indexOf('='); + const k = eq >= 0 ? items[i].substring(0, eq) : items[i]; + const v = eq >= 0 ? items[i].substring(eq + 1) : ''; + // Decode any existing encoding, then re-encode per RFC 3986 so the canonical + // form matches what the server recomputes. Fall back to raw on malformed input. + let dk, dv; + try { dk = decodeURIComponent(k); } catch (e) { dk = k; } + try { dv = decodeURIComponent(v); } catch (e) { dv = v; } + pairs.push({ k: rfc3986Encode(dk), v: rfc3986Encode(dv) }); + } + pairs.sort((a, b) => (a.k < b.k ? -1 : a.k > b.k ? 1 : a.v < b.v ? -1 : a.v > b.v ? 1 : 0)); + return pairs.map((p) => p.k + '=' + p.v).join('&'); +} + +function signStateRequest(method, path, rawQuery) { + if (!SIGNATURE_PRIVATE_KEY || !SIGNATURE_KEY_ID) return null; + const timestamp = String(Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()); + const nonce = String(UUID.randomUUID().toString()); + const stringToSign = [method, path, canonicalQuery(rawQuery), timestamp, nonce, SIGNATURE_KEY_ID].join('\n'); + const sig = Signature.getInstance('SHA256withECDSA'); + sig.initSign(SIGNATURE_PRIVATE_KEY); + sig.update(String(stringToSign).getBytes(StandardCharsets.UTF_8)); + return { + 'X-Algorithm': 'ECDSA-SHA256', + 'X-Timestamp': timestamp, + 'X-Nonce': nonce, + 'X-Key-Id': SIGNATURE_KEY_ID, + 'X-Signature': String(Base64.getEncoder().encodeToString(sig.sign())), + }; +} + function sendingRequest(msg, initiator, helper) { if (initiator === HttpSender.AUTHENTICATION_INITIATOR || !msg.isInScope()) return; const uri = msg.getRequestHeader().getURI(); - const kind = classifyRequest(String(uri.getHost()), String(uri.getPath())); + const host = String(uri.getHost()); + const path = String(uri.getPath()); + const kind = classifyRequest(host, path); const token = TOKENS[kind]; if (!token) { @@ -43,24 +123,28 @@ function sendingRequest(msg, initiator, helper) { return; } msg.getRequestHeader().setHeader('Authorization', 'Bearer ' + token); + + // State API requires ECDSA signature headers in addition to the bearer token. + // See backend/compact-connect/docs/client_signature_auth.md and owasp-zap/README.md. + if (kind === 'state') { + const method = String(msg.getRequestHeader().getMethod()); + const rawQuery = uri.getQuery(); + const sigHeaders = signStateRequest(method, path, rawQuery == null ? '' : String(rawQuery)); + if (sigHeaders) { + for (const name in sigHeaders) { + msg.getRequestHeader().setHeader(name, sigHeaders[name]); + } + } + } } function responseReceived(msg, initiator, helper) { const statusCode = msg.getResponseHeader().getStatusCode(); - const uri = msg.getRequestHeader().getURI(); print( statusCode, msg.getRequestHeader().getMethod(), - uri.toString() + msg.getRequestHeader().getURI().toString() ); - // TEMP: diagnose state /providers/query baseline — only the clean URL - const path = String(uri.getPath()); - if (String(uri.getHost()).indexOf('state-api.') === 0 && path === '/v1/compacts/aslp/jurisdictions/oh/providers/query') { - const ctype = msg.getRequestHeader().getHeader('Content-Type') || '(none)'; - const body = String(msg.getRequestBody().toString()).replace(/\n/g, ' ').substring(0, 300); - const resp = String(msg.getResponseBody().toString()).replace(/\n/g, ' ').substring(0, 300); - print('[state-query ' + statusCode + '] CT:' + ctype + ' REQ:' + body + ' RESP:' + resp); - } // To debug auth issues, uncomment this for a hint // if (statusCode === 401 || statusCode == 403 ) { // print('Request header:', msg.getRequestHeader().getHeader('Authorization').substring(0, 16)); diff --git a/owasp-zap/manual-scan.sh b/owasp-zap/manual-scan.sh index bb8ded111..9fb324978 100755 --- a/owasp-zap/manual-scan.sh +++ b/owasp-zap/manual-scan.sh @@ -60,6 +60,8 @@ docker run \ -e ZAP_AUTH_STAFF_TOKEN="$STAFF_TOKEN" \ -e ZAP_AUTH_PROVIDER_TOKEN="$PROVIDER_TOKEN" \ -e ZAP_AUTH_STATE_TOKEN="$STATE_TOKEN" \ + -e ZAP_STATE_SIGNATURE_PRIVATE_KEY="${STATE_SIGNATURE_PRIVATE_KEY:-}" \ + -e ZAP_STATE_SIGNATURE_KEY_ID="${STATE_SIGNATURE_KEY_ID:-}" \ -t zaproxy/zap-stable \ zap.sh -cmd \ -autorun /zap/wrk/owasp-zap/data/test-automation.yml