From ee5b648897663684ec569bc14b96caf4bde95ee2 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Tue, 24 Feb 2026 20:40:12 -0500 Subject: [PATCH 1/9] test --- test | 1 + 1 file changed, 1 insertion(+) create mode 100644 test diff --git a/test b/test new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/test @@ -0,0 +1 @@ +test From b05d787954ff85f5330bb209310455c7be903fa9 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Wed, 13 May 2026 23:44:09 -0500 Subject: [PATCH 2/9] Update to have proper security and all around updates!!! --- .env_example | 2 + .github/workflows/site.yml | 4 +- .gitignore | 4 + readme.md => README.md | 0 configs/bao_config.hcl | 4 +- configs/redis_config.conf | 2 +- docker-compose.yml | 5 +- docs/getting-started/configuration.md | 27 +-- docs/getting-started/installation.md | 76 +++++--- docs/guides/before-and-after.md | 235 +++++++++++++++++++++++++ docs/index.md | 17 +- docs/scripts/generate-certificates.md | 19 -- docs/scripts/setup.md | 2 + mkdocs.yml | 3 +- scripts/generate_certs.py | 243 -------------------------- scripts/requirements.txt | 1 - scripts/setup.py | 13 +- snykey/api/v1/endpoints.py | 108 +++++++----- snykey/core/config.py | 8 +- snykey/services/openbao.py | 36 ++-- snykey/services/redis.py | 42 ++--- snykey/services/snyk.py | 37 +++- snykey/tests/test_openbao.py | 7 +- snykey/tests/test_redis.py | 49 +----- snykey/tests/test_snyk.py | 29 +++ snykey/tests/test_v1_endpoints.py | 30 ++-- test | 1 - 27 files changed, 522 insertions(+), 482 deletions(-) rename readme.md => README.md (100%) create mode 100644 docs/guides/before-and-after.md delete mode 100644 docs/scripts/generate-certificates.md delete mode 100644 scripts/generate_certs.py delete mode 100644 scripts/requirements.txt delete mode 100644 test diff --git a/.env_example b/.env_example index 513b895..2c4dcb4 100644 --- a/.env_example +++ b/.env_example @@ -1,6 +1,8 @@ OPENBAO_ADDR=https://openbao:8200 OPENBAO_TOKEN=example_token OPENBAO_UNSEAL_KEY=example_key +# Path to a CA cert for verifying OpenBao TLS. Leave empty to use the system trust store (e.g. for Let's Encrypt certs). +OPENBAO_CA_CERT=/snykey/certs/ca/ca.crt REDIS_HOST=redis REDIS_PORT=6379 REDIS_PASSWORD=example_password diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index c8a6f21..e046300 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -22,8 +22,8 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r ./docs/requirements.txt + python -m pip install uv + uv pip install -r ./docs/requirements.txt - name: Build MkDocs site run: mkdocs build diff --git a/.gitignore b/.gitignore index 0c704cf..83b7070 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,7 @@ temp/ # Just for now docs/examples/snykey.py + +# Ignore private keys and certificates +*.key +*.pem diff --git a/readme.md b/README.md similarity index 100% rename from readme.md rename to README.md diff --git a/configs/bao_config.hcl b/configs/bao_config.hcl index 8679368..ea97504 100644 --- a/configs/bao_config.hcl +++ b/configs/bao_config.hcl @@ -14,7 +14,7 @@ } }, "default_lease_ttl": "168h", - "max_lease_ttl": "0h", + "max_lease_ttl": "720h", "ui": true, - "log_level": "Debug" + "log_level": "Info" } diff --git a/configs/redis_config.conf b/configs/redis_config.conf index 449004f..54cb8f9 100644 --- a/configs/redis_config.conf +++ b/configs/redis_config.conf @@ -1,5 +1,5 @@ port 6379 -loglevel debug +loglevel notice bind 0.0.0.0 requirepass example_password diff --git a/docker-compose.yml b/docker-compose.yml index 05e5b44..607c29f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: environment: - OPENBAO_ADDR=${OPENBAO_ADDR} - OPENBAO_TOKEN=${OPENBAO_TOKEN} + - OPENBAO_CA_CERT=${OPENBAO_CA_CERT} - REDIS_HOST=${REDIS_HOST} - REDIS_PORT=${REDIS_PORT} - REDIS_PASSWORD=${REDIS_PASSWORD} @@ -38,8 +39,6 @@ services: redis: image: "redis:latest" container_name: redis - ports: - - "6379:6379" volumes: - ./.container_volumes/redis/data:/data:rw,Z - ./.container_volumes/redis/config/redis.conf:/etc/redis.conf:ro,Z @@ -52,8 +51,6 @@ services: openbao: image: "openbao/openbao:latest" container_name: openbao - ports: - - "8200:8200" environment: - OPENBAO_ENV=development - BAO_ADDR=https://127.0.0.1:8200 diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index ab6edad..7e91365 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -6,18 +6,19 @@ Snykey is highly configurable to fit a variety of deployment environments. Below Most configuration is handled via environment variables, typically set in your `.env` file or passed directly to Docker Compose. See `.env_example` for a template. -| Variable | Description | Default/Example | -|---------------------|---------------------------------------------------------|-------------------------| -| `OPENBAO_ADDR` | URL for the OpenBao server | `https://openbao:8200` | -| `OPENBAO_TOKEN` | Root token for OpenBao | *(set after init)* | -| `OPENBAO_UNSEAL_KEY`| Unseal key for OpenBao | *(set after init)* | -| `REDIS_HOST` | Hostname for Redis | `redis` | -| `REDIS_PORT` | Port for Redis | `6379` | -| `REDIS_PASSWORD` | Password for Redis | `example_password` | -| `REDIS_CACHE_TIME` | Cache time (in seconds) for Redis tokens | `3000` | -| `REDIS_PKCE_EXPIRATION`| Cache time (in seconds) for app registration uri to have valid callback | `600`| -| `EXCLUDED_PATHS` | Comma seperated endpoints to ignore middleware for | `/docs,/openapi.json` | -| `API_KEY` | The key required for middleware, uses the header: 'X-API-Key'. To disable, remove the entry | `example_api_key_1234567890` | +| Variable | Description | Default/Example | +|---|---|---| +| `OPENBAO_ADDR` | URL for the OpenBao server | `https://openbao:8200` | +| `OPENBAO_TOKEN` | Root token for OpenBao | *(set after init)* | +| `OPENBAO_UNSEAL_KEY` | Unseal key for OpenBao | *(set after init)* | +| `OPENBAO_CA_CERT` | Path to a CA cert for verifying OpenBao TLS. Leave empty to use the system trust store (e.g. for Let's Encrypt or mkcert) | *(empty)* | +| `REDIS_HOST` | Hostname for Redis | `redis` | +| `REDIS_PORT` | Port for Redis | `6379` | +| `REDIS_PASSWORD` | Password for Redis | `example_password` | +| `REDIS_CACHE_TIME` | Cache time (in seconds) for Redis tokens | `3000` | +| `REDIS_PKCE_EXPIRATION` | Cache time (in seconds) for PKCE data during app registration | `600` | +| `EXCLUDED_PATHS` | Comma-separated endpoints to bypass API key middleware | `/docs,/openapi.json,/v1/callback` | +| `API_KEY` | The key required by middleware, sent via the `X-API-Key` header. Remove the entry to disable authentication | `example_api_key_1234567890` | You can modify these in your `.env` file or override them in your deployment environment. @@ -29,7 +30,7 @@ Redis is configured via [`configs/redis_config.conf`](../configs/redis_config.co You can adjust: - **Port**: Change the `port` directive. -- **Password**: Set `requirepass` to your desired password. +- **Password**: Set `requirepass` to your desired password. This must match `REDIS_PASSWORD` in your `.env`. - **Persistence**: Adjust `appendonly` and `appendfsync` for durability/performance. - **Logging**: Change `loglevel` as needed. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 52ec13a..255a430 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -12,72 +12,100 @@ cd snykey ``` ## 2. Prepare Configuration -Copy .env_example to .env and fill in your secrets and environment-specific values. -Adjust any configuration in configs/ if needed. -## 3. Run the Setup Scripts -The setup scripts will: +Copy `.env_example` to `.env` and fill in your values. You won't have the OpenBao token or unseal key yet — that comes after initialization in step 6. -* Create required directories and volume mounts -* Copy configuration files for OpenBao and Redis -* Generate self-signed certificates for local development +```bash +cp .env_example .env +``` -Run: `python3 ./scripts/setup.py` -Run: `python3 ./scripts/genreate_certs.py` +## 3. Run the Setup Script -## 4. Set the configuration -The setup needs an initial configuration .env file. You dont need to know the OPENBAO details just yet. +Creates the required directory structure and copies configuration files for OpenBao and Redis. ```bash -cp .env_example .env +python3 ./scripts/setup.py +``` + +## 4. Place TLS Certificates + +Snykey and OpenBao both require TLS certificates. Place them in the directories created by the setup script: + +| Path | Contents | +|------|----------| +| `.container_volumes/certs/ca/ca.crt` | CA certificate (used to verify OpenBao) | +| `.container_volumes/certs/bao/bao.crt` | OpenBao server certificate | +| `.container_volumes/certs/bao/bao.key` | OpenBao server key | +| `.container_volumes/certs/app/app.crt` | App server certificate | +| `.container_volumes/certs/app/app.key` | App server key | + +For local development, [`mkcert`](https://github.com/FiloSottile/mkcert) is the easiest way to generate trusted certificates: + +```bash +mkcert -install +mkcert -cert-file .container_volumes/certs/bao/bao.crt \ + -key-file .container_volumes/certs/bao/bao.key \ + openbao localhost 127.0.0.1 +mkcert -cert-file .container_volumes/certs/app/app.crt \ + -key-file .container_volumes/certs/app/app.key \ + app localhost 127.0.0.1 +cp "$(mkcert -CAROOT)/rootCA.pem" .container_volumes/certs/ca/ca.crt ``` +For production, use certificates from your organization's CA or a public provider. Set `OPENBAO_CA_CERT` in `.env` if OpenBao uses a certificate not trusted by the system store. + ## 5. Start the Stack -Start all services with Docker Compose: ```bash docker compose up -d --build ``` ## 6. Initialize and Unseal OpenBao + ### a. Initialize OpenBao (first time only): + ```bash docker exec -it openbao bao operator init -n 1 -t 1 ``` -* Save the Unseal Key and Root Token from the output. -* Add them to your .env file as OPENBAO_UNSEAL_KEY and OPENBAO_TOKEN. +Save the **Unseal Key** and **Root Token** from the output, then add them to your `.env` file: -### b. Restart the Stack -```bash -docker compose down ``` +OPENBAO_UNSEAL_KEY= +OPENBAO_TOKEN= +``` + +### b. Restart the Stack ```bash -docker compose up -d +docker compose restart ``` ### c. Unseal OpenBao: + ```bash docker exec -it openbao bao operator unseal $OPENBAO_UNSEAL_KEY ``` ### d. Login to OpenBao: + ```bash docker exec -it openbao bao login $OPENBAO_TOKEN ``` ## 7. Enable the Key-Value Secrets Engine -Enable the KV secrets engine (version 2): ```bash docker exec -it openbao bao secrets enable -version=2 kv ``` +(if that doesnt work try this: `docker exec -it openbao sh -c 'unset BAO_TOKEN && bao secrets enable -version=2 kv'`) + ## 8. (Optional) Verify Everything is Running -* Visit `https://localhost:8000/docs` for the API docs. -* Check logs in .container_volumes/app/logs/ if needed. + +- Visit `https://localhost:8000/docs` for the API docs. +- Check logs in `.container_volumes/app/logs/` if needed. ## 9. Next Steps -* Use the API to store and retrieve Snyk credentials for your applications. -* For production, replace the generated certificates with your own trusted certificates. + +- Use the API to store and retrieve Snyk credentials for your applications. diff --git a/docs/guides/before-and-after.md b/docs/guides/before-and-after.md new file mode 100644 index 0000000..268af92 --- /dev/null +++ b/docs/guides/before-and-after.md @@ -0,0 +1,235 @@ +# Before and After: Snykey vs. Standard App Management + +## An Example Secnario + +You have a Python service that periodically calls the Snyk API to list vulnerabilities for a project. It needs a valid Snyk access token to make that call. + +--- + +## Without Snykey + +Every application that touches the Snyk API must own the full credential lifecycle: + +- Persist the refresh token somewhere (file, database, environment variable) +- Detect when the access token has expired +- Call the Snyk OAuth2 token endpoint to get a new one +- Update the stored refresh token (tokens rotate on every refresh) +- Handle failures, retries, and race conditions if multiple workers refresh simultaneously + +### Initial App Registration + +To start, you'll need to register a Snyk app and complete the OAuth PKCE flow... That means: + * generating a code verifier + * building the authorization URL + * handling the redirect + * exchanging the code for tokens + +```python +import hashlib +import httpx +import json +import os +import secrets +import base64 +from urllib.parse import urlencode + +CLIENT_ID = os.environ["SNYK_CLIENT_ID"] +CLIENT_SECRET = os.environ["SNYK_CLIENT_SECRET"] +REDIRECT_URI = "https://yourapp.example.com/callback" +TOKEN_FILE = ".snyk_tokens.json" + + +def generate_code_verifier() -> str: + return base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + + +def generate_code_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + + +def get_auth_url(state: str, verifier: str) -> str: + challenge = generate_code_challenge(verifier) + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": "org.read org.project.read", + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + return "https://app.snyk.io/oauth2/authorize?" + urlencode(params) + + +def exchange_code(code: str, verifier: str) -> dict: + resp = httpx.post( + "https://api.snyk.io/oauth2/token", + data={ + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "redirect_uri": REDIRECT_URI, + "code": code, + "code_verifier": verifier, + }, + ) + resp.raise_for_status() + return resp.json() + + +# You must expose a /callback route, receive the code, call exchange_code(), and handle the persistance yourself. +def handle_callback(code: str, verifier: str): + tokens = exchange_code(code, verifier) + with open(TOKEN_FILE, "w") as f: + json.dump(tokens, f) +``` + +### Getting a Token at Runtime + +Every time your service calls Snyk, it must check whether the token is still valid and refresh it if not. + +```python +import time +import httpx +import json +import os + +CLIENT_ID = os.environ["SNYK_CLIENT_ID"] +CLIENT_SECRET = os.environ["SNYK_CLIENT_SECRET"] +TOKEN_FILE = ".snyk_tokens.json" + + +def load_tokens() -> dict: + with open(TOKEN_FILE) as f: + return json.load(f) + + +def save_tokens(tokens: dict): + with open(TOKEN_FILE, "w") as f: + json.dump(tokens, f) + + +def refresh_tokens(refresh_token: str) -> dict: + resp = httpx.post( + "https://api.snyk.io/oauth2/token", + data={ + "grant_type": "refresh_token", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "refresh_token": refresh_token, + }, + ) + resp.raise_for_status() + return resp.json() + + +def get_valid_token() -> str: + tokens = load_tokens() + + # Snyk access tokens expire check stored expiry if you tracked it, + # otherwise refresh proactively every call to be safe. + new_tokens = refresh_tokens(tokens["refresh_token"]) + + # Refresh tokens rotate meaning you must persist the new one immediately. + # If this write fails, the old refresh token is invalid and you're locked out. + save_tokens(new_tokens) + + return new_tokens["access_token"] + + +def list_projects(org_id: str) -> dict: + token = get_valid_token() + resp = httpx.get( + f"https://api.snyk.io/rest/orgs/{org_id}/projects", + headers={"Authorization": f"Bearer {token}"}, + ) + resp.raise_for_status() + return resp.json() +``` + +### Problems with this approach + +- **Secrets sprawl**: `CLIENT_SECRET` and the refresh token live in every service that calls Snyk. +- **Token rotation is fragile**: if `save_tokens()` fails after a refresh, the refresh token is burned and you must re-authenticate from scratch. +- **No caching**: each call to `get_valid_token()` hits the Snyk token endpoint, adding latency and burning rate limits unnecessarily. +- **Concurrency**: two workers refreshing simultaneously will invalidate each other's tokens. +- **Duplication**: this logic must be replicated in every service, in every language, that needs a Snyk token. + +--- + +## With Snykey + +Snykey handles registration, token storage, rotation, and caching. All your application only needs to know are two things: the Snykey endpoint and the org/client identifiers. + +### One-Time Setup + +Register the Snyk app once via the Snykey API. Snykey generates the PKCE parameters, registers the app with Snyk, stores the credentials, and returns the authorization URL. + +```bash +curl -s -X POST https://snykey.internal/v1/register-app \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $SNYKEY_API_KEY" \ + -d '{ + "name": "my-service", + "scopes": "org.read,org.project.read", + "redirect_uris": "https://yourapp.example.com/callback", + "org_id": "your-snyk-org-id", + "auth_token": "your-snyk-personal-token" + }' +``` + +The response includes an `auth_urls` field. Open that URL in a browser, authorize the app, and Snykey's `/v1/callback` endpoint handles the rest! (That's the only time you touch a browser) + +### Getting a Token at Runtime + +```python +import httpx +import os + +SNYKEY_URL = os.environ["SNYKEY_URL"] +SNYKEY_API_KEY = os.environ["SNYKEY_API_KEY"] +ORG_ID = os.environ["SNYK_ORG_ID"] +CLIENT_ID = os.environ["SNYK_CLIENT_ID"] +CLIENT_SECRET = os.environ["SNYK_CLIENT_SECRET"] + + +def get_snyk_token() -> str: + resp = httpx.post( + f"{SNYKEY_URL}/v1/credentials", + headers={"X-API-Key": SNYKEY_API_KEY}, + json={ + "org_id": ORG_ID, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + ) + resp.raise_for_status() + return resp.json()["access_token"] + + +def list_projects(org_id: str) -> dict: + token = get_snyk_token() + resp = httpx.get( + f"https://api.snyk.io/rest/orgs/{org_id}/projects", + headers={"Authorization": f"Bearer {token}"}, + ) + resp.raise_for_status() + return resp.json() +``` + +Snykey returns a cached token from Redis if one is still valid, or silently refreshes and rotates the refresh token in OpenBao before returning a new one. Your service sees none of that complexity. + +--- + +## Side-by-Side Summary + +| | Without Snykey | With Snykey | +|---|---|---| +| **Secrets in each service** | `CLIENT_SECRET` + refresh token | `CLIENT_SECRET` only (used to verify identity) | +| **Refresh token storage** | File, DB, or env var per service | OpenBao (one place) | +| **Token rotation** | Each service must persist new token immediately | Snykey handles it | +| **Caching** | Refresh on every call unless you add caching yourself | Redis cache built in | +| **Concurrency safety** | Race condition if multiple workers refresh simultaneously | Single point of refresh | +| **Lines of credential code per service** | ~60–80 | ~10 | +| **Re-authentication if token lost** | Manual browser flow per service | Never — Snykey retains the refresh token | diff --git a/docs/index.md b/docs/index.md index 54fc121..a253992 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,11 +30,12 @@ This removes the persistence and secrets management burden from your application ## Quick Start 1. **Clone the repository and configure your environment.** -2. **Run the `setup.sh` script** to prepare directories, configs, and certificates. -3. **Start the stack** with Docker Compose. -4. **Initialize and unseal OpenBao** (see [Installation](getting-started/installation.md) for details). -5. **Enable the KV secrets engine** in OpenBao. -6. **Use the API** to store and retrieve Snyk credentials for your applications. +2. **Run the `setup.py` script** to prepare directories and configs. +3. **Place TLS certificates** in `.container_volumes/certs/`. +4. **Start the stack** with Docker Compose. +5. **Initialize and unseal OpenBao** (see [Installation](getting-started/installation.md) for details). +6. **Enable the KV secrets engine** in OpenBao. +7. **Use the API** to store and retrieve Snyk credentials for your applications. ## Documentation Sections @@ -45,11 +46,9 @@ This removes the persistence and secrets management burden from your application ## Running Tests -To run the test suite: - ```bash -pip install -r tests/requirements.txt -pytest snykey/tests +pip install -r snykey/requirements.txt -r snykey/tests/requirements.txt +pytest ``` ## OpenBao Setup (Summary) diff --git a/docs/scripts/generate-certificates.md b/docs/scripts/generate-certificates.md deleted file mode 100644 index df71582..0000000 --- a/docs/scripts/generate-certificates.md +++ /dev/null @@ -1,19 +0,0 @@ -# Certificate Generation Script - -## Overview - -This Bash script automates the creation of a local Certificate Authority (CA) and generates signed TLS certificates for two entities: "OpenBao" and "App". It uses OpenSSL to generate private keys, certificate signing requests (CSRs), and signed certificates, including Subject Alternative Names (SANs) for both server and client authentication. The script is designed to be used in development or testing environments where self-signed certificates are sufficient. - -## Features - -- Creates a local CA with its own private key and certificate. -- Generates private keys and CSRs for "OpenBao" and "App". -- Signs the CSRs with the local CA, producing valid certificates. -- Supports custom configuration for certificate subject fields and SANs. -- Cleans up intermediate files and sets secure permissions on keys and certificates. - -## Source - -```python ---8<-- "scripts/generate_certs.py" -``` diff --git a/docs/scripts/setup.md b/docs/scripts/setup.md index fc41b0b..7f50c9f 100644 --- a/docs/scripts/setup.md +++ b/docs/scripts/setup.md @@ -11,6 +11,8 @@ This Python script automates the initial setup for Snykey. It creates the requir * Sets secure permissions on all created directories and files. * Provides clear output for each step, making it easy to verify setup progress. +After running the script, place your TLS certificates in the created `.container_volumes/certs/` directories before starting the stack. See [Installation](../getting-started/installation.md) for details. + ## Source ```python diff --git a/mkdocs.yml b/mkdocs.yml index 5596ae3..1faa65c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,10 +40,11 @@ nav: - Getting Started: - Installation: getting-started/installation.md - Configuration: getting-started/configuration.md + - Guides: + - Before and After: guides/before-and-after.md - Endpoints: - v1: endpoints/v1.md - Scripts: - - Generating Certificates: scripts/generate-certificates.md - Setup: scripts/setup.md docs_dir: docs diff --git a/scripts/generate_certs.py b/scripts/generate_certs.py deleted file mode 100644 index 0dbb2e1..0000000 --- a/scripts/generate_certs.py +++ /dev/null @@ -1,243 +0,0 @@ -import os -import argparse -import ipaddress -from cryptography import x509 -from cryptography.x509.oid import NameOID -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509 import DNSName, IPAddress, SubjectAlternativeName -from datetime import datetime, timedelta, timezone - -DEFAULT_CERT_DIR: str = ".container_volumes/certs" -DEFAULT_COUNTRY: str = "US" -DEFAULT_STATE: str = "State" -DEFAULT_CITY: str = "City" -DEFAULT_ORG: str = "OrgName" -DEFAULT_CA_CN: str = "InternalCA" -DEFAULT_BAO_CN: str = "openbao" -DEFAULT_APP_CN: str = "app" -DEFAULT_BAO_SANS: tuple[str] = ( - "DNS:openbao", - "DNS:localhost", - "DNS:host.docker.internal", - "IP:127.0.0.1", -) -DEFAULT_APP_SANS: tuple[str] = ( - "DNS:app", - "DNS:localhost", - "DNS:host.docker.internal", - "IP:127.0.0.1", -) - - -def parse_args() -> argparse.Namespace: - parser: argparse.ArgumentParser = argparse.ArgumentParser( - description="Generate CA, OpenBao, and App certificates." - ) - - parser.add_argument("-d", "--dir", default=DEFAULT_CERT_DIR, help="Output cert dir") - parser.add_argument("-c", "--country", default=DEFAULT_COUNTRY) - parser.add_argument("-s", "--state", default=DEFAULT_STATE) - parser.add_argument("-l", "--city", default=DEFAULT_CITY) - parser.add_argument("-o", "--org", default=DEFAULT_ORG) - parser.add_argument("--ca-cn", default=DEFAULT_CA_CN) - parser.add_argument("--bao-cn", default=DEFAULT_BAO_CN) - parser.add_argument("--app-cn", default=DEFAULT_APP_CN) - parser.add_argument("--bao-sans", default=",".join(DEFAULT_BAO_SANS)) - parser.add_argument("--app-sans", default=",".join(DEFAULT_APP_SANS)) - - return parser.parse_args() - - -def parse_sans(sans_str: str) -> list: - """ - Parse Subject Alternative Names (SANs) from a comma-separated string. - - Args: - sans_str (str): Comma-separated SANs, e.g. "DNS:example.com, IP:192.168.1.1" - - Returns: - list: List of SAN objects (DNSName or IPAddress). - """ - - sans: list = [] - - for entry in sans_str.split(","): - entry = entry.strip() - - if entry.startswith("DNS:"): - sans.append(DNSName(entry[4:])) - elif entry.startswith("IP:"): - sans.append(IPAddress(ipaddress.ip_address(entry[3:]))) - - return sans - - -def write_pem(filename: str, data: bytes, is_key: bool = False): - """ - Write PEM data to a file. - - Args: - filename (str): The file path to write the PEM data. - data (bytes): The PEM data to write. - is_key (bool): If True, the file is treated as a private key. - """ - - with open(filename, "wb") as f: - f.write(data) - - -def build_subject(country: str, state: str, city: str, org: str, cn: str) -> x509.Name: - """ - Build a X.500 distinguished name (DN) for a certificate subject. - - Args: - country (str): Country name (2-letter code). - state (str): State or province name. - city (str): Locality or city name. - org (str): Organization name. - cn (str): Common name (CN) for the certificate. - - Returns: - x509.Name: A X.500 distinguished name object. - """ - - return x509.Name( - [ - x509.NameAttribute(NameOID.COUNTRY_NAME, country), - x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state), - x509.NameAttribute(NameOID.LOCALITY_NAME, city), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, org), - x509.NameAttribute(NameOID.COMMON_NAME, cn), - ] - ) - - -def main(): - """ - Main function to generate CA, OpenBao, and App certificates. - """ - - args: argparse.Namespace = parse_args() - - cert_dir: str = os.path.abspath(args.dir) - ca_dir: str = os.path.join(cert_dir, "ca") - bao_dir: str = os.path.join(cert_dir, "bao") - app_dir: str = os.path.join(cert_dir, "app") - - os.makedirs(ca_dir, exist_ok=True) - os.makedirs(bao_dir, exist_ok=True) - os.makedirs(app_dir, exist_ok=True) - - # Generate CA key and cert - ca_key: rsa.RSAPrivateKey = rsa.generate_private_key( - public_exponent=65537, key_size=4096 - ) - ca_subject: x509.Name = build_subject( - args.country, args.state, args.city, args.org, args.ca_cn - ) - ca_cert: x509.Certificate = ( - x509.CertificateBuilder() - .subject_name(ca_subject) - .issuer_name(ca_subject) - .public_key(ca_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + timedelta(days=3650)) - .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) - .sign(private_key=ca_key, algorithm=hashes.SHA256()) - ) - write_pem( - os.path.join(ca_dir, "ca.key"), - ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ), - is_key=True, - ) - write_pem( - os.path.join(ca_dir, "ca.crt"), ca_cert.public_bytes(serialization.Encoding.PEM) - ) - - # Generate OpenBao key and cert - bao_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - bao_subject: x509.Name = build_subject( - args.country, args.state, args.city, args.org, args.bao_cn - ) - bao_sans: list[x509.Name] = parse_sans(args.bao_sans) - ( - x509.CertificateSigningRequestBuilder() - .subject_name(bao_subject) - .add_extension(SubjectAlternativeName(bao_sans), critical=False) - .sign(bao_key, hashes.SHA256()) - ) - bao_cert: x509.Certificate = ( - x509.CertificateBuilder() - .subject_name(bao_subject) - .issuer_name(ca_cert.subject) - .public_key(bao_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + timedelta(days=825)) - .add_extension(SubjectAlternativeName(bao_sans), critical=False) - .sign(private_key=ca_key, algorithm=hashes.SHA256()) - ) - write_pem( - os.path.join(bao_dir, "bao.key"), - bao_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ), - is_key=True, - ) - write_pem( - os.path.join(bao_dir, "bao.crt"), - bao_cert.public_bytes(serialization.Encoding.PEM), - ) - - # Generate App key and cert - app_key: rsa.RSAPrivateKey = rsa.generate_private_key( - public_exponent=65537, key_size=2048 - ) - app_subject: x509.Name = build_subject( - args.country, args.state, args.city, args.org, args.app_cn - ) - app_sans: list[x509.Name] = parse_sans(args.app_sans) - ( - x509.CertificateSigningRequestBuilder() - .subject_name(app_subject) - .add_extension(SubjectAlternativeName(app_sans), critical=False) - .sign(app_key, hashes.SHA256()) - ) - app_cert: x509.Certificate = ( - x509.CertificateBuilder() - .subject_name(app_subject) - .issuer_name(ca_cert.subject) - .public_key(app_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + timedelta(days=825)) - .add_extension(SubjectAlternativeName(app_sans), critical=False) - .sign(private_key=ca_key, algorithm=hashes.SHA256()) - ) - write_pem( - os.path.join(app_dir, "app.key"), - app_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ), - is_key=True, - ) - write_pem( - os.path.join(app_dir, "app.crt"), - app_cert.public_bytes(serialization.Encoding.PEM), - ) - - print(f"Certificate generation complete! Certificates are in: {cert_dir}") - - -if __name__ == "__main__": - main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt deleted file mode 100644 index ed207ea..0000000 --- a/scripts/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -cryptography==46.0.5 diff --git a/scripts/setup.py b/scripts/setup.py index 32af6a3..d85345c 100644 --- a/scripts/setup.py +++ b/scripts/setup.py @@ -16,7 +16,11 @@ def main(): # Create necessary directories dirs: list[str] = [ - "certs", + "certs/ca", + "certs/bao", + "certs/bao/ca", + "certs/app", + "certs/app/ca", "app/logs", "openbao/logs", "openbao/data", @@ -64,7 +68,12 @@ def main(): print("All setup steps completed.") print( - "You should now run the generate_certs.py script to create the necessary certificates." + "Place your TLS certificates in .container_volumes/certs/ before starting the stack.\n" + " certs/ca/ca.crt — CA certificate (used to verify OpenBao)\n" + " certs/bao/bao.crt — OpenBao server certificate\n" + " certs/bao/bao.key — OpenBao server key\n" + " certs/app/app.crt — App server certificate\n" + " certs/app/app.key — App server key" ) diff --git a/snykey/api/v1/endpoints.py b/snykey/api/v1/endpoints.py index 2103823..e5de6b4 100644 --- a/snykey/api/v1/endpoints.py +++ b/snykey/api/v1/endpoints.py @@ -1,6 +1,7 @@ from logging import getLogger, Logger from fastapi import APIRouter, Query from fastapi.responses import JSONResponse +from pydantic import BaseModel from services import openbao, snyk, redis, oauth from core.config import settings @@ -9,27 +10,47 @@ router: APIRouter = APIRouter() +class StoreCredentialsBody(BaseModel): + org_id: str + client_id: str + client_secret: str + refresh_key: str + instance: str = "api.snyk.io" + + +class GetCredentialsBody(BaseModel): + org_id: str + client_id: str + client_secret: str + instance: str = "api.snyk.io" + + +class RegisterAppBody(BaseModel): + name: str + scopes: str + redirect_uris: str + org_id: str + auth_token: str + instance: str = "api.snyk.io" + + @router.put("/credentials") -async def store_credentials( - org_id: str, client_id: str, client_secret: str, refresh_key: str -) -> JSONResponse: +async def store_credentials(body: StoreCredentialsBody) -> JSONResponse: """ Store Snyk credentials in OpenBao. Args: - org_id (str): The organization ID. - client_id (str): The client ID. - client_secret (str): The client secret. - refresh_key (str): The refresh key. + body (StoreCredentialsBody): Request body containing org_id, client_id, client_secret, and refresh_key. Returns: JSONResponse: A response indicating success or failure. """ - org_id = org_id.strip() - client_id = client_id.strip() - client_secret = client_secret.strip() - refresh_key = refresh_key.strip() + org_id = body.org_id.strip() + client_id = body.client_id.strip() + client_secret = body.client_secret.strip() + refresh_key = body.refresh_key.strip() + instance = body.instance.strip() if await openbao.check_vault_sealed(): return JSONResponse( @@ -40,7 +61,7 @@ async def store_credentials( logger.info("Refreshing key to ensure no other process can use it.") try: result: dict = await snyk.refresh_snyk_token( - client_id, client_secret, refresh_key + client_id, client_secret, refresh_key, instance ) logger.info( @@ -57,24 +78,22 @@ async def store_credentials( return JSONResponse(content={"message": "Credentials stored."}) -@router.get("/credentials") -async def get_credentials( - org_id: str, client_id: str, client_secret: str -) -> JSONResponse: +@router.post("/credentials") +async def get_credentials(body: GetCredentialsBody) -> JSONResponse: """ Gather Snyk credentials using the provided org_id and client_id. Args: - org_id (str): The organization ID. - client_id (str): The client ID. + body (GetCredentialsBody): Request body containing org_id, client_id, and client_secret. Returns: JSONResponse: A response containing the gathered credentials or an error message. """ - org_id = org_id.strip() - client_id = client_id.strip() - client_secret = client_secret.strip() + org_id = body.org_id.strip() + client_id = body.client_id.strip() + client_secret = body.client_secret.strip() + instance = body.instance.strip() # Check if auth token exists in Redis logger.info( @@ -122,7 +141,7 @@ async def get_credentials( try: result: dict = await snyk.refresh_snyk_token( - client_id, client_secret, refresh_key + client_id, client_secret, refresh_key, instance ) logger.info( @@ -209,22 +228,12 @@ async def delete_cache_key(org_id: str, client_id: str) -> JSONResponse: @router.post("/register-app") -async def register_app( - name: str, - scopes: str, - redirect_uris: str, - org_id: str, - auth_token: str, -) -> JSONResponse: +async def register_app(body: RegisterAppBody) -> JSONResponse: """ Register a new Snyk app with the specified parameters. Args: - name (str): Name of the Snyk app. - scopes (str): Comma-separated list of scopes for the Snyk app. - redirect_uris (str): Comma-separated list of redirect URIs for the Snyk app. The first one is assumed for storing PKCE data. - org_id (str): Snyk organization ID (stored in PKCE data for callback). - auth_token (str): Snyk authentication token. + body (RegisterAppBody): Request body containing name, scopes, redirect_uris, org_id, and auth_token. Returns: JSONResponse: A response containing the registered app details or an error message. @@ -247,14 +256,17 @@ async def register_app( logger.error("Failed to generate OAuth parameters: %s", e) return JSONResponse(status_code=500, content={"error": str(e)}) - org_id = org_id.strip() - auth_token = auth_token.strip() - name = name.strip() + org_id = body.org_id.strip() + auth_token = body.auth_token.strip() + name = body.name.strip() + instance = body.instance.strip() - scopes_list: list[str] = [s.strip() for s in scopes.split(",")] - redirect_uris_list: list[str] = [u.strip() for u in redirect_uris.split(",")] + scopes_list: list[str] = [s.strip() for s in body.scopes.split(",")] + redirect_uris_list: list[str] = [ + u.strip() for u in body.redirect_uris.split(",") if u.strip() + ] - if len(redirect_uris_list) == 0: + if not redirect_uris_list: return JSONResponse( status_code=400, content={"error": "At least one redirect URI is required"} ) @@ -263,7 +275,7 @@ async def register_app( try: result = await snyk.register_snyk_app( - name, scopes_list, redirect_uris_list, org_id, auth_token + name, scopes_list, redirect_uris_list, org_id, auth_token, instance=instance ) except Exception as e: logger.error("Failed to register Snyk app: %s", e) @@ -292,6 +304,7 @@ async def register_app( state=state, code_challenge=code_challenge, code_challenge_method="S256", + instance=instance, ) auth_urls[uri] = auth_url @@ -303,10 +316,9 @@ async def register_app( code_verifier=code_verifier, client_id=client_id, client_secret=client_secret, - redirect_uri=redirect_uris_list[ - 0 - ], # Assuming the first redirect URI is used for storing + redirect_uri=redirect_uris_list[0], org_id=org_id, + instance=instance, expiration=settings.REDIS_PKCE_EXPIRATION, ) @@ -317,7 +329,9 @@ async def register_app( async def oauth_callback( code: str = Query(..., description="Authorization code from Snyk"), state: str = Query(..., description="State parameter for CSRF protection"), - instance: str = Query(default="api.snyk.io", description="Snyk instance"), + instance: str = Query( + default="api.snyk.io", description="Snyk API hostname (e.g. api.eu.snyk.io)" + ), ) -> JSONResponse: """ OAuth callback endpoint to handle the authorization code flow. @@ -328,7 +342,7 @@ async def oauth_callback( Args: code (str): The authorization code from Snyk. state (str): The state parameter for CSRF protection. - instance (str): The Snyk instance (default: api.snyk.io). + instance (str): Snyk API hostname. Overridden by the value stored in PKCE data if present. Returns: JSONResponse: A response indicating success or failure. @@ -350,6 +364,7 @@ async def oauth_callback( client_secret: str = pkce_data.get("client_secret") redirect_uri: str = pkce_data.get("redirect_uri") org_id: str = pkce_data.get("org_id").strip() + instance = pkce_data.get("instance", instance).strip() if not all([code_verifier, client_id, client_secret, redirect_uri, org_id]): logger.error("Incomplete PKCE data for state: %s", state) @@ -374,6 +389,7 @@ async def oauth_callback( client_secret=client_secret, redirect_uri=redirect_uri, code_verifier=code_verifier, + instance=instance, ) refresh_token = token_response.get("refresh_token") diff --git a/snykey/core/config.py b/snykey/core/config.py index 0178ea6..5faab22 100644 --- a/snykey/core/config.py +++ b/snykey/core/config.py @@ -2,6 +2,8 @@ from dotenv import load_dotenv from pydantic_settings import BaseSettings +load_dotenv() + class Settings(BaseSettings): """ @@ -11,6 +13,7 @@ class Settings(BaseSettings): API_KEY (str): The API key for authenticating requests. OPENBAO_ADDR (str): Address of the OpenBao service, defaults to "http://localhost:8200". OPENBAO_TOKEN (str): Token for OpenBao authentication, defaults to "changeme". + OPENBAO_CA_CERT (str): Path to a CA cert for verifying OpenBao TLS. Empty string uses the system trust store. REDIS_HOST (str): Hostname for the Redis service, defaults to "redis". REDIS_PORT (int): Port for the Redis service, defaults to 6379. REDIS_PASSWORD (str): Password for Redis authentication, defaults to "example_password". @@ -21,6 +24,7 @@ class Settings(BaseSettings): API_KEY: str = os.getenv("API_KEY", "") OPENBAO_ADDR: str = os.getenv("OPENBAO_ADDR", "http://localhost:8200") OPENBAO_TOKEN: str = os.getenv("OPENBAO_TOKEN", "changeme") + OPENBAO_CA_CERT: str = os.getenv("OPENBAO_CA_CERT", "") REDIS_HOST: str = os.getenv("REDIS_HOST", "redis") REDIS_PORT: int = int(os.getenv("REDIS_PORT", 6379)) REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "example_password") @@ -29,9 +33,11 @@ class Settings(BaseSettings): os.getenv("REDIS_PKCE_EXPIRATION", 3600) ) # in seconds -load_dotenv() + settings: Settings = Settings() EXCLUDED_PATHS: list[str] = ( os.getenv("EXCLUDED_PATHS") if os.getenv("EXCLUDED_PATHS") else "" ).split(",") + +EXCLUDED_PATHS.append("/v1/callback") diff --git a/snykey/services/openbao.py b/snykey/services/openbao.py index e4f6444..0b80cc6 100644 --- a/snykey/services/openbao.py +++ b/snykey/services/openbao.py @@ -8,9 +8,10 @@ OPENBAO_TOKEN: str = settings.OPENBAO_TOKEN SECRET_MOUNT_POINT: str = "kv" +_ssl_verify: str | bool = settings.OPENBAO_CA_CERT if settings.OPENBAO_CA_CERT else True http_client: httpx.AsyncClient = httpx.AsyncClient( - verify=False, + verify=_ssl_verify, timeout=30.0, limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), ) @@ -34,11 +35,11 @@ async def check_vault_sealed() -> bool: data: dict = resp.json() sealed = data.get("sealed", True) - logger.info(f"Vault seal status check: sealed={sealed}, response={data}") + logger.info("Vault seal status check: sealed=%s, response=%s", sealed, data) return sealed except Exception as e: - logger.error(f"Failed to check Vault seal status: {str(e)}") - raise RuntimeError(f"Failed to check Vault seal status: {str(e)}") + logger.error("Failed to check Vault seal status: %s", str(e)) + raise RuntimeError("Failed to check Vault seal status: %s" % str(e)) async def store_refresh_key(org_id: str, client_id: str, refresh_token: str) -> bool: @@ -51,7 +52,10 @@ async def store_refresh_key(org_id: str, client_id: str, refresh_token: str) -> refresh_token: Refresh token from Snyk Returns: - bool: True if successful, False otherwise. + bool: True if successful. + + Raises: + Exception: If the request to OpenBao fails. """ url: str = f"{OPENBAO_ADDR}/v1/{SECRET_MOUNT_POINT}/data/snyk/{org_id}/{client_id}" @@ -68,9 +72,12 @@ async def store_refresh_key(org_id: str, client_id: str, refresh_token: str) -> return True except Exception as e: logger.error( - f"Failed to store refresh key for org {org_id}, client {client_id}: {str(e)}" + "Failed to store refresh key for org %s, client %s: %s", + org_id, + client_id, + str(e), ) - return False + raise async def get_refresh_key(org_id: str, client_id: str) -> str | None: @@ -123,7 +130,10 @@ async def delete_refresh_key(org_id: str, client_id: str) -> dict: return {"message": "Refresh key deleted."} except Exception as e: logger.error( - f"Failed to delete refresh key for org {org_id}, client {client_id}: {str(e)}" + "Failed to delete refresh key for org %s, client %s: %s", + org_id, + client_id, + str(e), ) return {"error": f"Failed to delete refresh key: {str(e)}"} @@ -139,6 +149,9 @@ async def update_refresh_key(org_id: str, client_id: str, refresh_key: str) -> d Returns: dict: A confirmation message indicating the refresh key was updated. + + Raises: + Exception: If the request to OpenBao fails. """ url: str = f"{OPENBAO_ADDR}/v1/{SECRET_MOUNT_POINT}/data/snyk/{org_id}/{client_id}" @@ -155,6 +168,9 @@ async def update_refresh_key(org_id: str, client_id: str, refresh_key: str) -> d return {"message": "Refresh key updated."} except Exception as e: logger.error( - f"Failed to update refresh key for org {org_id}, client {client_id}: {str(e)}" + "Failed to update refresh key for org %s, client %s: %s", + org_id, + client_id, + str(e), ) - return {"error": f"Failed to update refresh key: {str(e)}"} + raise diff --git a/snykey/services/redis.py b/snykey/services/redis.py index a19d463..86db8a3 100644 --- a/snykey/services/redis.py +++ b/snykey/services/redis.py @@ -1,6 +1,7 @@ import json import logging import redis.asyncio as redis +from redis.typing import ResponseT from core.config import settings logger: logging.Logger = logging.getLogger(__name__) @@ -62,9 +63,6 @@ async def get_auth_token(org_id: str, client_id: str) -> bytes | None: key: str = format_key(org_id, client_id) - if not await redis_client.exists(key): - return None - return await redis_client.get(key) @@ -82,34 +80,14 @@ async def delete_auth_token(org_id: str, client_id: str) -> dict: key: str = format_key(org_id, client_id) - if not await redis_client.exists(key): - return {"message": "Auth token not found."} + deleted: ResponseT = await redis_client.delete(key) - await redis_client.delete(key) + if not deleted: + return {"message": "Auth token not found."} return {"message": "Auth token deleted."} -async def check_token_age(org_id: str, client_id: str) -> int | None: - """ - Checks the age of the Snyk auth token for the specified org/client in Redis. - - Args: - org_id (str): The organization ID. - client_id (str): The client ID. - - Returns: - int | None: The age of the auth token in seconds if found, otherwise None. - """ - - key: str = format_key(org_id, client_id) - - if not await redis_client.exists(key): - return None - - return await redis_client.ttl(key) - - def format_pkce_key(state: str) -> str: """ Constructs a Redis key for storing PKCE and app info based on state. @@ -131,6 +109,7 @@ async def store_pkce_data( client_secret: str, redirect_uri: str, org_id: str, + instance: str = "api.snyk.io", code: str | None = None, expiration: int = 600, ) -> dict: @@ -144,6 +123,7 @@ async def store_pkce_data( client_secret (str): The Snyk app client secret. redirect_uri (str): The redirect URI for the OAuth flow. org_id (str): The Snyk organization ID. + instance (str): Snyk API hostname used during registration. code (str | None): Optional authorization code. expiration (int): Expiration time in seconds (default: 600 = 10 minutes). @@ -159,6 +139,7 @@ async def store_pkce_data( "client_secret": client_secret, "redirect_uri": redirect_uri, "org_id": org_id, + "instance": instance, "code": code, } @@ -180,9 +161,6 @@ async def get_pkce_data(state: str) -> dict | None: key: str = format_pkce_key(state) - if not await redis_client.exists(key): - return None - data: bytes | None = await redis_client.get(key) if data is None: @@ -204,10 +182,10 @@ async def delete_pkce_data(state: str) -> dict: key: str = format_pkce_key(state) - if not await redis_client.exists(key): - return {"message": "PKCE data not found."} + deleted: ResponseT = await redis_client.delete(key) - await redis_client.delete(key) + if not deleted: + return {"message": "PKCE data not found."} return {"message": "PKCE data deleted."} diff --git a/snykey/services/snyk.py b/snykey/services/snyk.py index 0ad173e..7405c53 100644 --- a/snykey/services/snyk.py +++ b/snykey/services/snyk.py @@ -8,8 +8,19 @@ ) +def _normalize_instance(instance: str) -> str: + instance = instance.strip().rstrip("/") + for prefix in ("https://", "http://"): + if instance.startswith(prefix): + instance = instance[len(prefix) :] + return instance + + async def refresh_snyk_token( - client_id: str, client_secret: str, refresh_token: str + client_id: str, + client_secret: str, + refresh_token: str, + instance: str = "api.snyk.io", ) -> dict: """ Use Snyk OAuth2 endpoint to exchange a refresh token for a new access token and refresh token. @@ -18,6 +29,7 @@ async def refresh_snyk_token( client_id (str): Snyk OAuth2 client ID. client_secret (str): Snyk OAuth2 client secret. refresh_token (str): The refresh token to exchange. + instance (str): Snyk API hostname (e.g. "api.snyk.io", "api.eu.snyk.io"). Returns: dict: A dictionary containing the new access token, refresh token, and expiration time. @@ -26,7 +38,7 @@ async def refresh_snyk_token( if not client_id or not client_secret or not refresh_token: raise ValueError("client_id, client_secret, and refresh_token must be provided") - url: str = "https://api.snyk.io/oauth2/token" + url: str = f"https://{_normalize_instance(instance)}/oauth2/token" headers: dict[str, str] = { "Content-Type": "application/x-www-form-urlencoded", @@ -58,6 +70,7 @@ async def exchange_code_for_token( client_secret: str, redirect_uri: str, code_verifier: str, + instance: str = "api.snyk.io", ) -> dict: """ Exchange an authorization code for access and refresh tokens using PKCE. @@ -68,6 +81,7 @@ async def exchange_code_for_token( client_secret (str): Snyk OAuth2 client secret. redirect_uri (str): The redirect URI used in the authorization request. code_verifier (str): The PKCE code verifier. + instance (str): Snyk API hostname (e.g. "api.snyk.io", "api.eu.snyk.io"). Returns: dict: A dictionary containing the access token, refresh token, and expiration time. @@ -82,7 +96,7 @@ async def exchange_code_for_token( ): raise ValueError("All parameters must be provided") - url: str = "https://api.snyk.io/oauth2/token" + url: str = f"https://{_normalize_instance(instance)}/oauth2/token" headers: dict[str, str] = { "Content-Type": "application/x-www-form-urlencoded", @@ -117,6 +131,7 @@ async def register_snyk_app( org_id: str, auth_token: str, api_version: str = "2024-10-15", + instance: str = "api.snyk.io", ) -> dict: """ Register a new Snyk app with the specified parameters. @@ -128,14 +143,14 @@ async def register_snyk_app( org_id (str): Snyk organization ID. auth_token (str): Snyk API authentication token. api_version (str): Snyk API version (default: "2024-10-15"). + instance (str): Snyk API hostname (e.g. "api.snyk.io", "api.eu.snyk.io"). Returns: dict: Response from the Snyk API containing the app registration details. """ - url: str = ( - f"https://api.snyk.io/rest/orgs/{org_id}/apps/creations?version={api_version}" - ) + host: str = _normalize_instance(instance) + url: str = f"https://{host}/rest/orgs/{org_id}/apps/creations?version={api_version}" headers: dict[str, str] = { "Authorization": f"token {auth_token}", @@ -172,9 +187,10 @@ def generate_auth_url( client_id: str, redirect_uri: str, scopes: list[str], - state: str = "state123", - code_challenge: str = "challenge123", + state: str, + code_challenge: str, code_challenge_method: str = "S256", + instance: str = "api.snyk.io", ) -> str: """ Generate the Snyk OAuth2 authorization URL. @@ -186,11 +202,14 @@ def generate_auth_url( state (str): State parameter for CSRF protection. code_challenge (str): PKCE code challenge. code_challenge_method (str): PKCE code challenge method. + instance (str): Snyk API hostname (e.g. "api.snyk.io", "api.eu.snyk.io"). Returns: str: The complete authorization URL. """ + app_instance: str = _normalize_instance(instance).replace("api.", "app.", 1) + params: dict = { "response_type": "code", "client_id": client_id, @@ -201,4 +220,4 @@ def generate_auth_url( "code_challenge_method": code_challenge_method, } - return "https://app.snyk.io/oauth2/authorize?" + urllib.parse.urlencode(params) + return f"https://{app_instance}/oauth2/authorize?" + urllib.parse.urlencode(params) diff --git a/snykey/tests/test_openbao.py b/snykey/tests/test_openbao.py index ba37cfb..398fea4 100644 --- a/snykey/tests/test_openbao.py +++ b/snykey/tests/test_openbao.py @@ -112,7 +112,7 @@ def mock_post(*args, **kwargs): @pytest.mark.asyncio async def test_store_refresh_key_error(org_id: str, client_id: str): """ - Test that store_refresh_key handles errors when storing the Snyk refresh token in OpenBao. + Test that store_refresh_key raises when storing the Snyk refresh token fails. Args: org_id (str): The organization ID. @@ -122,9 +122,8 @@ async def test_store_refresh_key_error(org_id: str, client_id: str): with patch.object( openbao.http_client, "post", side_effect=Exception("Connection failed") ): - result = await openbao.store_refresh_key(org_id, client_id, "refresh_token") - - assert result is False + with pytest.raises(Exception, match="Connection failed"): + await openbao.store_refresh_key(org_id, client_id, "refresh_token") @pytest.mark.asyncio diff --git a/snykey/tests/test_redis.py b/snykey/tests/test_redis.py index 5dfc676..9ed4448 100644 --- a/snykey/tests/test_redis.py +++ b/snykey/tests/test_redis.py @@ -73,7 +73,6 @@ async def test_get_auth_token_found(mock_redis, org_id: str, client_id: str): client_id (str): The client ID. """ - mock_redis.exists = AsyncMock(return_value=True) mock_redis.get = AsyncMock(return_value=b"token") result: bytes | None = await redis_service.get_auth_token(org_id, client_id) @@ -92,7 +91,7 @@ async def test_get_auth_token_not_found(mock_redis, org_id: str, client_id: str) client_id (str): The client ID. """ - mock_redis.exists = AsyncMock(return_value=False) + mock_redis.get = AsyncMock(return_value=None) result: bytes | None = await redis_service.get_auth_token(org_id, client_id) assert result is None @@ -110,7 +109,6 @@ async def test_delete_auth_token_found(mock_redis, org_id: str, client_id: str): client_id (str): The client ID. """ - mock_redis.exists = AsyncMock(return_value=True) mock_redis.delete = AsyncMock(return_value=1) result: dict = await redis_service.delete_auth_token(org_id, client_id) mock_redis.delete.assert_awaited_with("snyk:org1:client1") @@ -130,49 +128,12 @@ async def test_delete_auth_token_not_found(mock_redis, org_id: str, client_id: s client_id (str): The client ID. """ - mock_redis.exists = AsyncMock(return_value=False) + mock_redis.delete = AsyncMock(return_value=0) result: dict = await redis_service.delete_auth_token(org_id, client_id) assert result == {"message": "Auth token not found."} -@pytest.mark.asyncio -@patch.object(redis_service, "redis_client") -async def test_check_token_age_found(mock_redis, org_id: str, client_id: str): - """ - Test checking the age of an auth token in Redis when it exists. - - Args: - mock_redis (AsyncMock): Mocked Redis client. - org_id (str): The organization ID. - client_id (str): The client ID. - """ - - mock_redis.exists = AsyncMock(return_value=True) - mock_redis.ttl = AsyncMock(return_value=42) - result: int | None = await redis_service.check_token_age(org_id, client_id) - - assert result == 42 - - -@pytest.mark.asyncio -@patch.object(redis_service, "redis_client") -async def test_check_token_age_not_found(mock_redis, org_id: str, client_id: str): - """ - Test checking the age of an auth token in Redis when it does not exist. - - Args: - mock_redis (AsyncMock): Mocked Redis client. - org_id (str): The organization ID. - client_id (str): The client ID. - """ - - mock_redis.exists = AsyncMock(return_value=False) - result: int | None = await redis_service.check_token_age(org_id, client_id) - - assert result is None - - def test_format_pkce_key(): """ Test the format_pkce_key function to ensure it constructs the Redis key correctly. @@ -234,7 +195,6 @@ async def test_get_pkce_data_found(mock_redis): "code": "auth_code", } - mock_redis.exists = AsyncMock(return_value=True) mock_redis.get = AsyncMock(return_value=json.dumps(pkce_data).encode()) result: dict | None = await redis_service.get_pkce_data("state123") @@ -252,7 +212,7 @@ async def test_get_pkce_data_not_found(mock_redis): mock_redis (AsyncMock): Mocked Redis client. """ - mock_redis.exists = AsyncMock(return_value=False) + mock_redis.get = AsyncMock(return_value=None) result: dict | None = await redis_service.get_pkce_data("state123") @@ -269,7 +229,6 @@ async def test_delete_pkce_data_found(mock_redis): mock_redis (AsyncMock): Mocked Redis client. """ - mock_redis.exists = AsyncMock(return_value=True) mock_redis.delete = AsyncMock(return_value=1) result: dict = await redis_service.delete_pkce_data("state123") @@ -288,7 +247,7 @@ async def test_delete_pkce_data_not_found(mock_redis): mock_redis (AsyncMock): Mocked Redis client. """ - mock_redis.exists = AsyncMock(return_value=False) + mock_redis.delete = AsyncMock(return_value=0) result: dict = await redis_service.delete_pkce_data("state123") diff --git a/snykey/tests/test_snyk.py b/snykey/tests/test_snyk.py index 6322c90..30fc75b 100644 --- a/snykey/tests/test_snyk.py +++ b/snykey/tests/test_snyk.py @@ -180,3 +180,32 @@ def test_generate_auth_url(): assert "code_challenge=challenge123" in url assert "code_challenge_method=S256" in url assert "response_type=code" in url + + +def test_generate_auth_url_eu_instance(): + """ + Test that generate_auth_url uses the correct app hostname for regional instances. + """ + + url: str = snyk.generate_auth_url( + client_id="client123", + redirect_uri="https://example.com/callback", + scopes=["org.read"], + state="state123", + code_challenge="challenge123", + instance="api.eu.snyk.io", + ) + + assert "https://app.eu.snyk.io/oauth2/authorize?" in url + + +def test_normalize_instance_strips_protocol_and_slash(): + """ + Test that _normalize_instance strips https://, http://, and trailing slashes. + """ + + assert snyk._normalize_instance("https://api.snyk.io/") == "api.snyk.io" + assert snyk._normalize_instance("http://api.snyk.io") == "api.snyk.io" + assert snyk._normalize_instance("api.snyk.io/") == "api.snyk.io" + assert snyk._normalize_instance("api.snyk.io") == "api.snyk.io" + assert snyk._normalize_instance("https://api.eu.snyk.io/") == "api.eu.snyk.io" diff --git a/snykey/tests/test_v1_endpoints.py b/snykey/tests/test_v1_endpoints.py index 962c5f6..99a4668 100644 --- a/snykey/tests/test_v1_endpoints.py +++ b/snykey/tests/test_v1_endpoints.py @@ -4,6 +4,7 @@ from main import app from core import config + class APIKeyTestClient(TestClient): def request(self, *args, **kwargs): headers = kwargs.pop("headers", None) @@ -14,6 +15,7 @@ def request(self, *args, **kwargs): return super().request(*args, headers=headers, **kwargs) + client = APIKeyTestClient(app) @@ -40,7 +42,7 @@ def get_req() -> dict: Fixture for getting credentials request. Returns: - dict: A dictionary containing organization ID and client ID. + dict: A dictionary containing organization ID, client ID, and client secret. """ return {"org_id": "org1", "client_id": "client1", "client_secret": "secret1"} @@ -76,7 +78,7 @@ async def async_check_vault_sealed(): async_check_vault_sealed, ) - async def async_refresh_snyk_token(cid, cs, rk): + async def async_refresh_snyk_token(cid, cs, rk, instance): return { "access_token": "token", "refresh_token": "refresh", @@ -96,7 +98,7 @@ async def async_store_refresh_key(o, c, r): async_store_refresh_key, ) - response = client.put("/v1/credentials", params=store_req) + response = client.put("/v1/credentials", json=store_req) assert response.status_code == 200 assert response.json() == {"message": "Credentials stored."} @@ -119,7 +121,7 @@ async def async_check_vault_sealed(): async_check_vault_sealed, ) - response = client.put("/v1/credentials", params=store_req) + response = client.put("/v1/credentials", json=store_req) assert response.status_code == 503 assert "Vault is sealed" in response.json()["error"] @@ -142,7 +144,7 @@ async def async_check_vault_sealed(): async_check_vault_sealed, ) - async def async_refresh_snyk_token(cid, cs, rk): + async def async_refresh_snyk_token(cid, cs, rk, instance): raise Exception("fail") monkeypatch.setattr( @@ -150,7 +152,7 @@ async def async_refresh_snyk_token(cid, cs, rk): async_refresh_snyk_token, ) - response = client.put("/v1/credentials", params=store_req) + response = client.put("/v1/credentials", json=store_req) assert response.status_code == 500 assert "fail" in response.json()["error"] @@ -173,7 +175,7 @@ async def async_get_auth_token(o, c): async_get_auth_token, ) - response = client.get("/v1/credentials", params=get_req) + response = client.post("/v1/credentials", json=get_req) assert response.status_code == 200 assert response.json() == {"access_token": "token"} @@ -204,7 +206,7 @@ async def async_get_refresh_key(o, c): async_get_refresh_key, ) - response = client.get("/v1/credentials", params=get_req) + response = client.post("/v1/credentials", json=get_req) assert response.status_code == 404 @@ -242,7 +244,7 @@ async def async_refresh_snyk_token(cid, cs, rk): async_refresh_snyk_token, ) - response = client.get("/v1/credentials", params=get_req) + response = client.post("/v1/credentials", json=get_req) assert response.status_code == 500 @@ -351,7 +353,7 @@ async def async_generate_state(): async_generate_state, ) - async def async_register_snyk_app(name, scopes, uris, org, token): + async def async_register_snyk_app(name, scopes, uris, org, token, instance): return { "data": { "attributes": {"client_id": "client123", "client_secret": "secret123"} @@ -381,7 +383,7 @@ async def async_store_pkce_data(**kwargs): response = client.post( "/v1/register-app", - params={ + json={ "name": "test_app", "scopes": "org.read", "redirect_uris": "https://example.com/callback", @@ -410,6 +412,7 @@ async def async_get_pkce_data(state): "client_secret": "secret123", "redirect_uri": "https://example.com/callback", "org_id": "org123", + "instance": "api.snyk.io", } monkeypatch.setattr( @@ -426,7 +429,7 @@ async def async_check_vault_sealed(): ) async def async_exchange_code_for_token( - code, client_id, client_secret, redirect_uri, code_verifier + code, client_id, client_secret, redirect_uri, code_verifier, instance ): return { "access_token": "access123", @@ -522,6 +525,7 @@ async def async_get_pkce_data(state): "client_secret": "secret123", "redirect_uri": "https://example.com/callback", "org_id": "org123", + "instance": "api.snyk.io", } monkeypatch.setattr( @@ -538,7 +542,7 @@ async def async_check_vault_sealed(): ) async def async_exchange_code_for_token( - code, client_id, client_secret, redirect_uri, code_verifier + code, client_id, client_secret, redirect_uri, code_verifier, instance ): raise Exception("Exchange failed") diff --git a/test b/test deleted file mode 100644 index 9daeafb..0000000 --- a/test +++ /dev/null @@ -1 +0,0 @@ -test From c70c1fc3536479f2d9448ffd2fb175fd5cd1b620 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Thu, 14 May 2026 16:26:52 -0500 Subject: [PATCH 3/9] Automatic openbao unseal and health checks --- Dockerfile | 2 ++ docker-compose.yml | 6 +++++ snykey/api/health.py | 48 +++++++++++++++++++++++++++++++++++++ snykey/api/v1/endpoints.py | 12 ++++++---- snykey/core/config.py | 2 ++ snykey/main.py | 2 ++ snykey/services/openbao.py | 49 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 snykey/api/health.py diff --git a/Dockerfile b/Dockerfile index 25ccb42..2ab0489 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM ghcr.io/astral-sh/uv:python3.13-alpine +RUN apk add --no-cache curl + RUN addgroup -g 1001 -S snykey && \ adduser -u 1001 -S snykey -G snykey diff --git a/docker-compose.yml b/docker-compose.yml index 607c29f..9c79972 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,12 @@ services: - ./.container_volumes/app/logs:/snykey/logs:rw,Z - ./.container_volumes/certs/app:/snykey/certs:rw,z - ./.container_volumes/certs/ca:/snykey/certs/ca:rw,z + healthcheck: + test: ["CMD", "curl", "-k", "-f", "https://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s depends_on: - openbao - redis diff --git a/snykey/api/health.py b/snykey/api/health.py new file mode 100644 index 0000000..70632b2 --- /dev/null +++ b/snykey/api/health.py @@ -0,0 +1,48 @@ +from logging import getLogger, Logger +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from services import openbao, redis + +logger: Logger = getLogger(__name__) + +router: APIRouter = APIRouter() + + +@router.get("/health") +async def health() -> JSONResponse: + """ + Reports the health of the application and its dependencies. + + Returns: + JSONResponse: Health status of the app, Redis, and OpenBao. + """ + + status: dict = { + "redis": "ok", + "openbao": "ok", + } + healthy: bool = True + + try: + await redis.redis_client.ping() + except Exception as e: + logger.warning("Health check: Redis unreachable: %s", e) + status["redis"] = "unreachable" + healthy = False + + try: + sealed: bool = await openbao.ensure_vault_unsealed() + if sealed: + status["openbao"] = "sealed" + healthy = False + except Exception as e: + logger.warning("Health check: OpenBao unreachable: %s", e) + status["openbao"] = "unreachable" + healthy = False + + status["status"] = "ok" if healthy else "degraded" + + return JSONResponse( + status_code=200 if healthy else 503, + content=status, + ) diff --git a/snykey/api/v1/endpoints.py b/snykey/api/v1/endpoints.py index e5de6b4..f37719e 100644 --- a/snykey/api/v1/endpoints.py +++ b/snykey/api/v1/endpoints.py @@ -52,10 +52,10 @@ async def store_credentials(body: StoreCredentialsBody) -> JSONResponse: refresh_key = body.refresh_key.strip() instance = body.instance.strip() - if await openbao.check_vault_sealed(): + if not await openbao.ensure_vault_unsealed(): return JSONResponse( status_code=503, - content={"error": "Vault is sealed, cannot store credentials."}, + content={"error": "Vault is sealed and could not be unsealed."}, ) logger.info("Refreshing key to ensure no other process can use it.") @@ -370,11 +370,13 @@ async def oauth_callback( logger.error("Incomplete PKCE data for state: %s", state) return JSONResponse(status_code=500, content={"error": "Incomplete PKCE data"}) - if await openbao.check_vault_sealed(): - logger.error("Vault is sealed, cannot proceed with OAuth callback") + if not await openbao.ensure_vault_unsealed(): + logger.error( + "Vault is sealed and could not be unsealed, cannot proceed with OAuth callback" + ) return JSONResponse( status_code=503, - content={"error": "Vault is sealed, cannot store credentials."}, + content={"error": "Vault is sealed and could not be unsealed."}, ) refresh_token: str | None = None diff --git a/snykey/core/config.py b/snykey/core/config.py index 5faab22..acd9f56 100644 --- a/snykey/core/config.py +++ b/snykey/core/config.py @@ -24,6 +24,7 @@ class Settings(BaseSettings): API_KEY: str = os.getenv("API_KEY", "") OPENBAO_ADDR: str = os.getenv("OPENBAO_ADDR", "http://localhost:8200") OPENBAO_TOKEN: str = os.getenv("OPENBAO_TOKEN", "changeme") + OPENBAO_UNSEAL_KEY: str = os.getenv("OPENBAO_UNSEAL_KEY", "") OPENBAO_CA_CERT: str = os.getenv("OPENBAO_CA_CERT", "") REDIS_HOST: str = os.getenv("REDIS_HOST", "redis") REDIS_PORT: int = int(os.getenv("REDIS_PORT", 6379)) @@ -41,3 +42,4 @@ class Settings(BaseSettings): ).split(",") EXCLUDED_PATHS.append("/v1/callback") +EXCLUDED_PATHS.append("/health") diff --git a/snykey/main.py b/snykey/main.py index ad9d2d1..32e9dbc 100644 --- a/snykey/main.py +++ b/snykey/main.py @@ -2,6 +2,7 @@ import core.config from api.v1 import endpoints +from api import health import logging @@ -99,3 +100,4 @@ def custom_openapi() -> dict: logger.warning("API Key is not set. The API is running without authentication!") app.include_router(endpoints.router, prefix="/v1") +app.include_router(health.router) diff --git a/snykey/services/openbao.py b/snykey/services/openbao.py index 0b80cc6..789ea1e 100644 --- a/snykey/services/openbao.py +++ b/snykey/services/openbao.py @@ -6,6 +6,7 @@ OPENBAO_ADDR: str = settings.OPENBAO_ADDR OPENBAO_TOKEN: str = settings.OPENBAO_TOKEN +OPENBAO_UNSEAL_KEY: str = settings.OPENBAO_UNSEAL_KEY SECRET_MOUNT_POINT: str = "kv" _ssl_verify: str | bool = settings.OPENBAO_CA_CERT if settings.OPENBAO_CA_CERT else True @@ -42,6 +43,54 @@ async def check_vault_sealed() -> bool: raise RuntimeError("Failed to check Vault seal status: %s" % str(e)) +async def unseal_vault() -> bool: + """ + Attempts to unseal the Vault using the configured unseal key. + + Returns: + bool: True if the Vault is unsealed after the attempt, False otherwise. + """ + + if not OPENBAO_UNSEAL_KEY: + logger.error("No unseal key configured, cannot unseal Vault") + return False + + url: str = f"{OPENBAO_ADDR}/v1/sys/unseal" + headers: dict[str, str] = {"X-Vault-Token": OPENBAO_TOKEN} + + try: + resp: httpx.Response = await http_client.post( + url, headers=headers, json={"key": OPENBAO_UNSEAL_KEY} + ) + resp.raise_for_status() + data: dict = resp.json() + + sealed: bool = data.get("sealed", True) + logger.info("Vault unseal attempt result: sealed=%s", sealed) + return not sealed + except Exception as e: + logger.error("Failed to unseal Vault: %s", str(e)) + return False + + +async def ensure_vault_unsealed() -> bool: + """ + Checks if the Vault is sealed and attempts to unseal it if so. + + Returns: + bool: True if the Vault is unsealed, False if it remains sealed. + """ + + try: + if not await check_vault_sealed(): + return True + except Exception: + return False + + logger.info("Vault is sealed, attempting automatic unseal") + return await unseal_vault() + + async def store_refresh_key(org_id: str, client_id: str, refresh_token: str) -> bool: """ Stores the Snyk refresh token in OpenBao. From 67e5a4fb4293e5f81808d7e8052657579ba2497e Mon Sep 17 00:00:00 2001 From: Will Hellinger <111201130+Will-Hellinger@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:48:19 -0400 Subject: [PATCH 4/9] fix reversed health logic Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- snykey/api/health.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snykey/api/health.py b/snykey/api/health.py index 70632b2..9ee40b8 100644 --- a/snykey/api/health.py +++ b/snykey/api/health.py @@ -31,8 +31,8 @@ async def health() -> JSONResponse: healthy = False try: - sealed: bool = await openbao.ensure_vault_unsealed() - if sealed: + unsealed: bool = await openbao.ensure_vault_unsealed() + if not unsealed: status["openbao"] = "sealed" healthy = False except Exception as e: From 02e2f57ebf19bf9fc8bd15e7c7bf02bb102bd90e Mon Sep 17 00:00:00 2001 From: Will Hellinger <111201130+Will-Hellinger@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:49:42 -0400 Subject: [PATCH 5/9] fix typo Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/guides/before-and-after.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/before-and-after.md b/docs/guides/before-and-after.md index 268af92..916a760 100644 --- a/docs/guides/before-and-after.md +++ b/docs/guides/before-and-after.md @@ -1,6 +1,6 @@ # Before and After: Snykey vs. Standard App Management -## An Example Secnario +## An Example Scenario You have a Python service that periodically calls the Snyk API to list vulnerabilities for a project. It needs a valid Snyk access token to make that call. From 1ba522bdf3d3cc37fd3c005445c9bb25e59e2664 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:51:39 +0000 Subject: [PATCH 6/9] Fix test monkeypatch signature for refresh token error case --- snykey/tests/test_v1_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snykey/tests/test_v1_endpoints.py b/snykey/tests/test_v1_endpoints.py index 99a4668..0824797 100644 --- a/snykey/tests/test_v1_endpoints.py +++ b/snykey/tests/test_v1_endpoints.py @@ -236,7 +236,7 @@ async def async_get_refresh_key(o, c): async_get_refresh_key, ) - async def async_refresh_snyk_token(cid, cs, rk): + async def async_refresh_snyk_token(cid, cs, rk, instance): raise Exception("fail") monkeypatch.setattr( From 01990eee3851cca11b0edd1802f556ae7d37272c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:52:36 +0000 Subject: [PATCH 7/9] Restrict Snyk instance hostnames for outbound token URLs --- snykey/services/snyk.py | 7 ++++++- snykey/tests/test_snyk.py | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/snykey/services/snyk.py b/snykey/services/snyk.py index 7405c53..73cafd9 100644 --- a/snykey/services/snyk.py +++ b/snykey/services/snyk.py @@ -1,6 +1,9 @@ +import re import httpx import urllib.parse +_VALID_SNYK_INSTANCE = r"^api(?:\.[a-z0-9-]+)?\.snyk\.io$" + http_client: httpx.AsyncClient = httpx.AsyncClient( verify=True, timeout=30.0, @@ -9,10 +12,12 @@ def _normalize_instance(instance: str) -> str: - instance = instance.strip().rstrip("/") + instance = instance.strip().rstrip("/").lower() for prefix in ("https://", "http://"): if instance.startswith(prefix): instance = instance[len(prefix) :] + if not re.match(_VALID_SNYK_INSTANCE, instance): + raise ValueError("instance must be api.snyk.io or api..snyk.io") return instance diff --git a/snykey/tests/test_snyk.py b/snykey/tests/test_snyk.py index 30fc75b..b1a8d19 100644 --- a/snykey/tests/test_snyk.py +++ b/snykey/tests/test_snyk.py @@ -209,3 +209,11 @@ def test_normalize_instance_strips_protocol_and_slash(): assert snyk._normalize_instance("api.snyk.io/") == "api.snyk.io" assert snyk._normalize_instance("api.snyk.io") == "api.snyk.io" assert snyk._normalize_instance("https://api.eu.snyk.io/") == "api.eu.snyk.io" + + +def test_normalize_instance_rejects_invalid_host(): + with pytest.raises(ValueError): + snyk._normalize_instance("evil.example.com") + + with pytest.raises(ValueError): + snyk._normalize_instance("api.snyk.io.attacker.com") From a330c99591e56af3faccef6589ab4b9405712d10 Mon Sep 17 00:00:00 2001 From: Will Hellinger <111201130+Will-Hellinger@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:00:12 -0400 Subject: [PATCH 8/9] another typo for persistance Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/guides/before-and-after.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/before-and-after.md b/docs/guides/before-and-after.md index 916a760..f676cc1 100644 --- a/docs/guides/before-and-after.md +++ b/docs/guides/before-and-after.md @@ -78,7 +78,7 @@ def exchange_code(code: str, verifier: str) -> dict: return resp.json() -# You must expose a /callback route, receive the code, call exchange_code(), and handle the persistance yourself. +# You must expose a /callback route, receive the code, call exchange_code(), and handle the persistence yourself. def handle_callback(code: str, verifier: str): tokens = exchange_code(code, verifier) with open(TOKEN_FILE, "w") as f: From 4591de9ea532ba930fd9324a15064131d557a601 Mon Sep 17 00:00:00 2001 From: Will Hellinger <111201130+Will-Hellinger@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:00:38 -0400 Subject: [PATCH 9/9] grammer. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/getting-started/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 255a430..da2c076 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -99,7 +99,7 @@ docker exec -it openbao bao login $OPENBAO_TOKEN docker exec -it openbao bao secrets enable -version=2 kv ``` -(if that doesnt work try this: `docker exec -it openbao sh -c 'unset BAO_TOKEN && bao secrets enable -version=2 kv'`) +(If that doesn't work, try: `docker exec -it openbao sh -c 'unset BAO_TOKEN && bao secrets enable -version=2 kv'`) ## 8. (Optional) Verify Everything is Running