diff --git a/.github/workflows/zap-scan-test.yml b/.github/workflows/zap-scan-test.yml index b399dd5e4..24bf1cfb0 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 @@ -39,20 +42,54 @@ jobs: run: yarn install --ignore-engines working-directory: 'owasp-zap/authenticator' - - name: Authenticate ZAP User + - name: Authenticate Staff User + env: + 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 --mode=staff | jq -r '.accessToken') + if [[ -z "$token" || "$token" == "null" ]]; then + echo "::error::Failed to obtain staff authentication token" + exit 1 + fi + echo "::add-mask::$token" + echo "ZAP_AUTH_STAFF_TOKEN=$token" >> "$GITHUB_ENV" + working-directory: 'owasp-zap/authenticator' + + - name: Authenticate Provider 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 }} + 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 | 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 authentication token" + echo "::error::Failed to obtain provider authentication token" exit 1 fi - echo "ZAP_AUTH_HEADER_VALUE=$token" >> "$GITHUB_ENV" - echo "Authentication successful" + 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_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 + 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,11 +100,25 @@ 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 + 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 b870798d4..6d2335d8f 100644 --- a/owasp-zap/README.md +++ b/owasp-zap/README.md @@ -1,38 +1,115 @@ # 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. 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 -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. This client needs two things that differ from a standard state onboarding client: + + - 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. + + 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: + + 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: + + ``` + TEST_COGNITO_STATE_AUTH_DOMAIN (e.g. compact-connect-state-auth-test.auth.us-east-1.amazoncognito.com) + 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") + ``` + +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 -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. 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 e5542d1cf..5b7084474 100644 --- a/owasp-zap/authenticator/.env.example +++ b/owasp-zap/authenticator/.env.example @@ -1,4 +1,28 @@ -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 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/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..f0ec23a8c 100644 --- a/owasp-zap/data/bearer-token.js +++ b/owasp-zap/data/bearer-token.js @@ -1,26 +1,140 @@ /* * 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 + * + * 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_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 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 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\/[^\/]+$/; + +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 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) { - // 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 + if (initiator === HttpSender.AUTHENTICATION_INITIATOR || !msg.isInScope()) return; + + const uri = msg.getRequestHeader().getURI(); + const host = String(uri.getHost()); + const path = String(uri.getPath()); + const kind = classifyRequest(host, path); + const token = TOKENS[kind]; + + if (!token) { + print('No ' + kind + ' token available for ' + uri.toString()); + 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]); + } } - msg.getRequestHeader().setHeader( - 'Authorization', - 'Bearer ' + token - ); } } 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/enrich-oas-for-zap.py b/owasp-zap/enrich-oas-for-zap.py index 3df727c3c..0f3f3c264 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", @@ -26,6 +28,32 @@ } +# 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. + # 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": "2026-04-15T00:00:00Z", + "endDateTime": "2026-04-22T00: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"} @@ -49,6 +77,20 @@ 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 ( + 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, # which is technically invalid OpenAPI and causes ZAP's parser to fail. for name, schema in spec.get("components", {}).get("schemas", {}).items(): @@ -83,16 +125,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__": diff --git a/owasp-zap/manual-scan.sh b/owasp-zap/manual-scan.sh index c76141d27..9fb324978 100755 --- a/owasp-zap/manual-scan.sh +++ b/owasp-zap/manual-scan.sh @@ -1,14 +1,67 @@ -#/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 + # 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() { + 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" \ + -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