Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
01b615e
feat(server): cache server-type detection per base_url per process
VanDelinea Jun 8, 2026
403e774
add explicit server_type override; bound + harden detection cache
VanDelinea Jun 22, 2026
f097ac7
fix(server): 🐛 keep server_type override per-instance; pin requests==…
VanDelinea Jun 23, 2026
354441b
Fix code style issues with Black
lint-action Jun 23, 2026
5e8925f
🛡️ bump requests 2.34.2 & urllib3 2.7.0; drop Python 3.8/3.9
VanDelinea Jul 23, 2026
e176656
Merge branch 'feature/server-detection' of https://github.com/Delinea…
VanDelinea Jul 23, 2026
e62c0ca
Merge pull request #96 from DelineaXPM/dependencies/741117-requests-u…
VanDelinea Jul 23, 2026
7c899ac
Clean resolve of requirements.txt
VanDelinea Jul 28, 2026
e3e4c4e
Merge branch 'feature/server-detection' of https://github.com/Delinea…
VanDelinea Jul 28, 2026
e6279d5
fix(server): 🐛 add missing http timeouts, refresh grant before expiry…
VanDelinea Aug 6, 2026
0b7c584
fix(server): 🐛 warn on plaintext http, tighten health-check validatio…
VanDelinea Aug 7, 2026
0caf843
ci: 🚀 scope workflow permissions, sha-pin the publish action, move re…
VanDelinea Aug 8, 2026
6651d67
fix(server): 🐛 thread-safe utc token refresh, fix latent bugs, 📘 add …
VanDelinea Aug 11, 2026
a62f6c9
Merge pull request #98 from DelineaXPM/security/review_remediation
VanDelinea Aug 12, 2026
3605dd9
feat(server): FileAttachment replaces Response in file fields
VanDelinea Sep 11, 2026
720cd09
Revert "feat(server): FileAttachment replaces Response in file fields"
VanDelinea Sep 11, 2026
fedc4b8
Reapply "feat(server): FileAttachment replaces Response in file fields"
VanDelinea Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,25 @@ on:
branches:
- main

# Default to read-only; the lint job below grants itself the write scopes
# lint-action actually needs (auto-fix commits + check-run annotations).
permissions:
contents: read

jobs:
lint:
name: Run black linter
runs-on: ubuntu-latest
permissions:
contents: write # auto_fix: true pushes formatting commits back to the branch
checks: write # lint-action publishes results as a check run
steps:
- name: Check out Git repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- name: Install Python dependencies
run: pip install black
run: pip install black==26.5.1 # match the pin in requirements-dev.txt
- name: Run black
uses: wearerequired/lint-action@548d8a7c4b04d3553d32ed5b6e91eb171e10e7bb # v2
with:
Expand Down
24 changes: 18 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@ on:
tags:
- 'v*'

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
# Required for PyPI Trusted Publishing (OIDC) below; no PYPI_API_TOKEN
# secret is used or needed once a trusted publisher is configured.
id-token: write

steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
Expand All @@ -18,14 +26,18 @@ jobs:

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flit
python -m pip install --upgrade "pip>=26.2" # CVE-2026-8643, CVE-2026-6357, CVE-2026-13346, CVE-2026-3219
python -m pip install flit==3.12.0 # match flit_core pin in pyproject.toml

- name: Build package
run: flit build

- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
# Security review item SDK-5 (PR #98): migrated from a long-lived
# PYPI_API_TOKEN to PyPI Trusted Publishing (OIDC), and the action ref
# is now SHA-pinned (it was previously the mutable `release/v1` branch).
# REQUIRES: a trusted publisher for this repo + workflow file must be
# configured on pypi.org (project Settings -> Publishing) before this
# tag push will succeed. Coordinate with the PyPI project owner first;
# keep the PYPI_API_TOKEN repo secret until that is confirmed working.
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
14 changes: 11 additions & 3 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ name: Run Tests

on: [pull_request]

# This workflow only checks out code and runs the test suite; it never
# writes to the repo or opens PRs/issues, so read-only is sufficient.
permissions:
contents: read

jobs:

build:
runs-on: ubuntu-latest
environment: testing
strategy:
matrix:
python: [3.8, 3.9, "3.10", "3.11"]
# Python 3.8/3.9 dropped: fixed requests/urllib3 pins require Python >= 3.10 (work item 741117)
python: ["3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
Expand All @@ -20,8 +26,10 @@ jobs:

- name: Install Tox
run: |
python -m pip install --upgrade pip
pip install tox
# Upgrading pip has to happen in the outer interpreter; a pin in a
# requirements file cannot replace the running pip.
python -m pip install --upgrade "pip>=26.2" # CVE-2026-8643, CVE-2026-6357, CVE-2026-13346, CVE-2026-3219
python -m pip install tox

- name: Run Tox
# Run tox using the version of Python in `PATH`
Expand Down
91 changes: 80 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ There are three ways in which you can authorize the `SecretServer` and `SecretSe

#### Password Authorization

If using traditional `username` and `password` authentication to log in to your Secret Server either directly or through Platform, you can pass the `PasswordGrantAuthorizer` into the `SecretServer` class at instantiation. The `PasswordGrantAuthorizer` requires a `base_url`, `username`, and `password`. It optionally takes a `token_path_uri`, but defaults to `/oauth2/token` or `/identity/api/oauth2/token/xpmplatform`, depending on whether a secret server or platform is used for authentication.
If using traditional `username` and `password` authentication to log in to your Secret Server either directly or through Platform, you can pass the `PasswordGrantAuthorizer` into the `SecretServer` class at instantiation. The `PasswordGrantAuthorizer` requires a `base_url`, `username`, and `password`. It optionally takes a `token_path_uri`, but defaults to `/oauth2/token` or `/identity/api/oauth2/token/xpmplatform`, depending on whether a secret server or platform is used for authentication. It also optionally takes a `server_type` (`"secret_server"` or `"platform"`) to skip automatic server-type detection — see [Server-Type Detection](#server-type-detection).

##### With Secret Server
```python
Expand All @@ -50,7 +50,7 @@ authorizer = PasswordGrantAuthorizer("https://platform.delinea.app", os.getenv("

#### Domain Authorization

To use a domain credential, use the `DomainPasswordGrantAuthorizer`. It requires a `base_url`, `username`, `domain`, and `password`. It optionally takes a `token_path_uri`, but defaults to `/oauth2/token`. It is applicable only when authentication is done using a secret server.
To use a domain credential, use the `DomainPasswordGrantAuthorizer`. It requires a `base_url`, `username`, `domain`, and `password`. It optionally takes a `token_path_uri`, but defaults to `/oauth2/token`, and a `server_type` (see [Server-Type Detection](#server-type-detection)). It is applicable only when authentication is done using a secret server.

```python
from delinea.secrets.server import DomainPasswordGrantAuthorizer
Expand All @@ -60,7 +60,7 @@ authorizer = DomainPasswordGrantAuthorizer("https://hostname/SecretServer", os.g

#### Access Token Authorization

If you already have an `access_token` of Secret Server or Platform user, you can pass directly via the `AccessTokenAuthorizer`. The `AccessTokenAuthorizer` requires a `access_token` and `base_url`.
If you already have an `access_token` of Secret Server or Platform user, you can pass directly via the `AccessTokenAuthorizer`. The `AccessTokenAuthorizer` requires a `access_token` and `base_url`. It optionally takes a `server_type` (see [Server-Type Detection](#server-type-detection)).

##### With Secret Server
```python
Expand All @@ -77,6 +77,25 @@ from delinea.secrets.server import AccessTokenAuthorizer
authorizer = AccessTokenAuthorizer("AgJ1slfZsEng9bKsssB-tic0Kh8I...", "https://platform.delinea.app")
```

#### Server-Type Detection

Unless given an explicit `server_type`, an authorizer detects whether the `base_url` points at a Secret Server or a Platform instance by probing its health-check endpoints (`/api/v1/healthcheck` then `/health`). `AccessTokenAuthorizer` probes when it is constructed; `PasswordGrantAuthorizer` and `DomainPasswordGrantAuthorizer` probe on their first token request, so constructing one does not validate the URL. The result is cached per `base_url` for the lifetime of the process, so the probe pair normally fires once per `base_url`. `SecretServerV0` accepts the same `server_type` keyword and passes it to the authorizer it builds.

You can skip detection entirely by passing an explicit `server_type` of either `"secret_server"` or `"platform"`. When supplied, no health-check probe is issued. This is recommended for callers that run each lookup in a fresh, short-lived process (for example, some Ansible lookup-plugin runtimes), where a fresh process cannot benefit from the in-process cache and the repeated unauthenticated probes can be rate-limited to `403` by the Delinea Platform WAF.

```python
from delinea.secrets.server import AccessTokenAuthorizer

# No health-check probe is issued; the type is used directly.
authorizer = AccessTokenAuthorizer(
"AgJ1slfZsEng9bKsssB-tic0Kh8I...",
"https://platform.delinea.app",
server_type="platform",
)
```

An explicit `server_type` applies only to the instance that supplies it and is never written to the shared cache, so it cannot affect auto-detection for other authorizers. If a `base_url` is ever re-provisioned to a different server type while a long-lived process is running, call `Authorizer.clear_server_type_cache()` to force re-detection.

## Secret Server Cloud

The SDK API requires an `Authorizer` and either a `tenant` or a `base_url`. In the case of plaform authentication, only a `base_url` is supported.
Expand All @@ -85,7 +104,7 @@ The SDK API requires an `Authorizer` and either a `tenant` or a `base_url`. In t

### Useage

Instantiate the `SecretServerCloud` class with `tenant` or `base_url`, along with an `Authorizer` (when providing `tenant`, yoou may optionally include a `tld`). To retrieve a secret, pass an integer `id` to `get_secret()` which will return the secret as a JSON encoded string.
Instantiate the `SecretServerCloud` class with `tenant` or `base_url`, along with an `Authorizer` (when providing `tenant`, yoou may optionally include a `tld`). To retrieve a secret, pass an integer `id` to `get_secret()` which will return the secret as a `dict`.

##### With Secret Server
```python
Expand Down Expand Up @@ -139,7 +158,7 @@ from delinea.secrets.server import SecretServer
secret_server = SecretServer(base_url="https://platform.delinea.app", authorizer=authorizer)
```

Secrets can be fetched using the `get_secret` method, which takes an integer `id` of the secret and, returns a `json` object:
Secrets can be fetched using the `get_secret` method, which takes an integer `id` of the secret and returns a `dict`:

```python
secret = secret_server.get_secret(os.getenv("TSS_SECRET_ID"))
Expand All @@ -159,7 +178,7 @@ secret = ServerSecret(**secret_server.get_secret(os.getenv("TSS_SECRET_ID")))
username = secret.fields['username'].value
```

It is also now possible to fetch a secret by the secrets `path` using the `get_secret_by_path` method on the `SecretServer` object. This, too, returns a `json` object.
It is also now possible to fetch a secret by the secrets `path` using the `get_secret_by_path` method on the `SecretServer` object. This, too, returns a `dict`.

```python
secret = secret_server.get_secret_by_path(r"TSS_SECRET_PATH")
Expand All @@ -182,13 +201,56 @@ except SecretServerError as e:

> Note: The `path` must be the full folder path and name of the secret.

### File Attachments

`get_secret()` and `get_secret_by_path()` fetch file attachments by default.
Every field with a non-zero `fileAttachmentId` gets its `itemValue` replaced
with a `FileAttachment` (importable from `delinea.secrets.server`): the file's
bytes, plus `.content`, `.text` and `.encoding`. Releases up to 2.0.1 stored
the `requests.Response` there, so every other member of it — `.status_code`,
`.json()`, `.headers`, `.ok`, `.iter_content()` — now raises `AttributeError`.
`.text` prefers a strict UTF-8 decode when the server declares Latin-1, which
`requests` reports for any `text/*` body with no charset. `.filename` and
`.encoding` carry what the server sent, or `None`.

```python
import os
import pathlib

secret = secret_server.get_secret(os.getenv("TSS_SECRET_ID"))
downloads = pathlib.Path("downloads")
downloads.mkdir(parents=True, exist_ok=True)

for item in secret["items"]:
if item.get("fileAttachmentId"):
# `filename` is server data: name the file yourself rather than
# joining it into a path, and do not rely on the key being present.
target = downloads / f"{secret['id']}_{item['slug']}"
target.write_bytes(item["itemValue"].content)
```

Use `.content` for any attachment, and `.text` only for one you know is text.
An empty attachment is falsy, like any empty `bytes`, so test
`item.get("fileAttachmentId")` rather than the value itself. Some templates
omit that key entirely, which is why the example reads it with `.get`.

Treat the value as read-once. Every `bytes` operation on it — slicing,
concatenation, `.strip()` — returns plain `bytes` and drops `.filename`,
`.encoding` and `.text`, and two attachments with identical contents compare
equal whatever their filenames. Copy what you need out before transforming.

`repr()` of a `FileAttachment` reports its size, not its contents, so an
attachment cannot leak through a log line. The secret's other field values are
ordinary strings, so never log the secret itself. `json.dumps()` of a fetched
secret raises on the bytes: pass `fetch_file_attachments=False` for JSON.

## Using Self-Signed Certificates

When using a self-signed certificate for SSL, the `REQUESTS_CA_BUNDLE` environment variable should be set to the path of the certificate (in `.pem` format). This will negate the need to ignore SSL certificate verification, which makes your application vunerable. Please reference the [`requests` documentation](https://docs.python.org/3/library/ssl.html) for further details on the `REQUESTS_CA_BUNDLE` environment variable, should you require it.

## Create a Build Environment (optional)

The SDK requires [Python 3.8](https://www.python.org/downloads/) or higher.
The SDK requires [Python 3.10](https://www.python.org/downloads/) or higher.

First, ensure Python is in `$PATH`, then run:

Expand All @@ -201,12 +263,19 @@ cd python-tss-sdk
python -m venv venv
. venv/bin/activate

# Install dependencies
python -m pip install --upgrade pip
pip install -r requirements.txt
# Install dependencies (runtime + test/build tooling)
python -m pip install --upgrade "pip>=26.2"
pip install -r requirements-dev.txt
```

Most of the suite runs offline and needs no credentials or network access:

```shell
pytest tests/test_security_phase1.py tests/test_security_phase2.py \
tests/test_security_phase4.py tests/test_server_detection_cache.py
```

Valid credentials are required to run the unit tests. The credentials should be stored in environment variables or in a `.env` file:
Valid credentials are required to run the live integration tests in `tests/test_server.py`. The credentials should be stored in environment variables or in a `.env` file:

```shell
export TSS_USERNAME=myusername
Expand Down
28 changes: 28 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Security Policy

## Supported Versions

Security fixes are released against the latest published version of `python-tss-sdk` on PyPI. We do not backport fixes to older minor/major versions; please upgrade to the latest release to receive security patches.

## Reporting a Vulnerability

If you believe you have found a security vulnerability in this SDK, please report it responsibly through Delinea's coordinated disclosure program rather than opening a public GitHub issue:

- **Trust Portal (preferred):** <https://trust.delinea.com/>
- **Email:** <security@delinea.com>

Please include:

- A description of the vulnerability and its potential impact.
- Steps to reproduce, including a minimal code sample against this SDK if applicable.
- The SDK version (`delinea.__version__`) and Python version in use.

Do not include real credentials, tokens, or secret values from a live Secret Server/Platform tenant in a report.

## What to Expect

Delinea's security team acknowledges and triages reports submitted through the channels above; response times and disclosure timelines are governed by the program terms published at <https://trust.delinea.com/>. Please do not disclose a suspected vulnerability publicly until it has been addressed.

## Scope

This policy covers the SDK code in this repository (`delinea/secrets/server.py` and related packaging). Vulnerabilities in Secret Server, Delinea Platform, or other Delinea products should be reported through the same channels above, which will route them to the appropriate team.
5 changes: 4 additions & 1 deletion delinea/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
"""The Delinea Secret Server Python SDK"""

__version__ = "2.0.1"
# 3.0.0, not 2.0.2: this line is the published version (flit reads it), and
# the branch carries three breaking changes -- the attachment ``itemValue``
# type, requires-python >= 3.10, and the requests floor. See work item 741117.
__version__ = "3.0.0"
Loading
Loading