Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-05
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Design

## Context

See `proposal.md`. `_mask_url_candidate` in `src/devops_cli/security/sanitizer.py` handles each URL that `redact_text` finds in free text. `mask_uri_credentials` handles one URL. Both return a URL with no password as it was.

## Goals / Non-Goals

**Goals:**

- A token in the user name of a URL does not show in masked text.
- The rule is written in the docs of the sanitizer and held by tests.

**Non-Goals:**

- A user name that holds a `/`, `?` or `#` that is not percent-encoded. It is not valid userinfo. The parser reads the part after the character as the host or the path, so no rule can mask it.
- A URL with no `://`, such as the scp form `git@host:org/repo.git`. `redact_text` finds a URL by its `://` and has never changed such a text.
- The keyword rules for `token` and `password`. Issue #2613 owns them, and its pull request is merged.

## Decisions

### 1. Mask a user name with no password

The user name is masked in `redact_text` and in `mask_uri_credentials`, in every scheme.

- *Rationale*: a service can take a token as the user name. httpx2 sends the URL as Basic authentication, so the user name is a credential. A parser cannot tell a token from a harmless name. The safe rule masks both.
- *Rationale*: pip masks a user name with no password in the same way.
- *Rejected alternative*: mask only a user name that looks like a token, with `_looks_like_a_credential`. A token with no digit, no capital and fewer than 24 characters would show. A token can be a plain word, and a name such as `git` is also a plain word, so the check cannot split them.
- *Rejected alternative*: keep a list of harmless names such as `git`. The list grows with each service, and a token that matches an entry would show.
- *Cost*: `ssh://git@host/path` shows as `ssh://<masked-user>@host/path` in masked output. The host and the path still show, so the text keeps its use. The message of the `clone-url` check of the Gitea doctor (`devops k8s doctor`) for a wrong `ssh_url` is the one such text in the tests, and its test now expects `<masked-user>`.

### 2. Use the parser that each function already uses

`_mask_url_candidate` reads `urlsplit` and `mask_uri_credentials` reads `httpx2.URL`. Each already finds the userinfo. The change adds one branch to each: a user name with no password.

- *Rejected alternative*: a regex for `scheme://user@`. The issue and `AGENTS.md` ask for a standard parser. A regex fails on IPv6 hosts, on `@` in a query and on a password with `@`, which #1109 fixed.

### 3. Keep the shape of the old placeholders

`redact_text` shows `<masked-user>` for a user name alone, as it shows `<masked-user>:<masked-password>` for a user and a password. `mask_uri_credentials` shows `***` for a user name alone, as it shows `***` for a password with no user. A user name with a password keeps the name and shows `user:***`, so the three forms of one function stay consistent.

- *Rejected alternative*: a new placeholder such as `<masked-token>`. The sanitizer does not know that the name is a token.

### 4. An empty user name stays

`http://@example.com/x` has an empty user name and no password. It holds no credential, so `redact_text` returns it as it was. `mask_uri_credentials` keeps its fail-closed rule from #1109 and returns `<masked-url>` for a text that has an `@` and no userinfo, and this change does not touch that rule. The form `http://:@example.com/` has userinfo, so `mask_uri_credentials` returns it as it was.

## Risks / Trade-offs

- [Risk] A harmless user name in a clone URL is masked, so a person reads `ssh://<masked-user>@host/path` and not the name.
- Mitigation: the host and the path stay. A person who needs the name reads the config or the remote, which the masker does not touch. The choice is one place in the module docstring, so a person can reverse it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Proposal

## Why

Issue #2662: `redact_text` and `mask_uri_credentials` in `src/devops_cli/security/sanitizer.py` leave the user name of a URL as written when the URL has no password.

- `_mask_url_candidate` masks the userinfo only when `parts.password is not None`. `mask_uri_credentials` masks it only when `u.password` is set. A URL such as `http://<token>@example.com:11434/api/embed` has a user name and no password, so both functions return it unchanged.
- Some services take a token as the user name, with an empty password. httpx2 sends such a URL as Basic authentication, and a token can be in `ai.ollama_urls`. An error text that quotes the URL then shows the token. The same text with a user name and a password shows `<masked-user>:<masked-password>`. The two cases differ.
- A parser cannot tell a token from a harmless name such as `git` in `ssh://git@host/`. The issue asks for a rule. The rule goes in the docs of the sanitizer and in its tests.

## What Changes

- The rule: the user name of a URL is masked when the URL has no password. The scheme does not matter. A harmless name is masked too, because a parser cannot tell it from a token.
- `redact_text` shows `<masked-user>` in place of the user name. The host, the port, the path, the query and the fragment stay as written.
- `mask_uri_credentials` shows `***` in place of the user name. A user name with a password keeps its name and shows `user:***`, as before.
- The module docstring of `sanitizer.py` states the rule, and the docstrings of `redact_text` and `mask_uri_credentials` point to it.
- The output of `redact_text`, `mask_secrets` and `mask_uri_credentials` is a result that a consumer sees. So the change carries a spec delta. It adds three requirements to the capability `secret-masking`: the user name with no password is masked, a URL with no user name stays as written, and a user name with a password keeps its masking.
- The tests that held the old behavior now hold the new rule: the cases `custom://myuser@example.com/path` and `https://ghp_x@example.com/o/r` in `tests/ai/client/test_url_credential_masking.py`, the case `user-only` in `tests/security/test_secret_redaction.py`, and the message of the case `ssh-url-form-names-the-tunnel-host` in `tests/k8s/test_gitea_doctor.py`, which quotes `ssh://git@<host>:2222/...`.
- This change fits one pull request.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `secret-masking`: the user name of a URL with no password is masked.

## Impact

- `src/devops_cli/security/sanitizer.py`.
- `tests/security/test_secret_redaction.py`, `tests/security/test_redaction_properties.py`, `tests/ai/client/test_url_credential_masking.py` and `tests/k8s/test_gitea_doctor.py`.
- A text with `ssh://git@host/path` now shows `ssh://<masked-user>@host/path` in masked output, for example in the message of the Gitea check of `devops k8s doctor` for a wrong `ssh_url`. This is the cost of the rule, and the design records it.

## Feasibility

- On `origin/main` at `f6d57ecd`, `redact_text("error calling http://tok_0123456789abcdef@example.com:11434/api/embed failed")` returned the text as it was. The same text with `user:pw12345` as the userinfo returned `<masked-user>:<masked-password>`. `mask_uri_credentials("http://tok_0123456789abcdef@example.com:11434/api/embed")` returned the URL as it was. The premise of the issue holds.
- httpx2 2.13.1 sends such a URL as Basic authentication. `httpx2.Client().build_request` on `http://tok_0123456789abcdef@example.com:11434/api/embed` gives a `BasicAuth` flow, and the request carries an `authorization` header. So a token in the user name is a real credential.
- `urllib.parse.urlsplit` gives `username='tok_0123456789abcdef'` and `password=None` for the same URL. `httpx2.URL` gives the user name and an empty password. Both parsers separate the userinfo from the host, so the change needs no pattern of its own. The IPv6 host `[2001:db8::1]:11434` and an `@` in a query stay correct, and the tests cover both.
- pip 26.3 masks a user name with no password. `redact_auth_from_url("http://tok_0123456789abcdef@example.com/x")` returns `http://****@example.com/x`, and the form with a password returns `http://user:****@example.com/x`. So the rule follows an open, maintained tool.
- Issue #1109 (closed) made the parsers `urllib` and httpx2. Pull request #3639 (issue #2613) changed `sanitizer.py` and is merged. It changed the keyword rules and not the URL step, so its behavior stays: prose after a token colon is unmasked. No open pull request touches `sanitizer.py`.
- The refine step of the issue answered the open question: mask the user name. The reason is least exposure, as a token and a name look the same. A person can reverse the choice, and the module docstring and the tests are the one place to change.

## Existing Tool Considered

The alternatives check of the issue (2026-11-04) found no tool that scans free text and masks a user name with a placeholder. The change uses the two parsers that the module already uses, `urllib.parse.urlsplit` and `httpx2.URL`. It adds no pattern and no helper. pip uses the same rule for its own output, as the Feasibility section shows. The change deletes no internal code.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## ADDED Requirements

### Requirement: The user name of a URL is masked when the URL has no password
The secret masking system SHALL mask the user name of a URL that has a user name and no password, whatever the scheme. `redact_text` and `mask_secrets` SHALL show `<masked-user>` in place of the user name, in data and code syntax. `mask_uri_credentials` SHALL show `***`. The scheme, the host, the port, the path, the query and the fragment SHALL stay as written.

#### Scenario: A token used as the user name in a text
- **WHEN** secret masking processes `error calling http://tok_0123456789abcdef@example.com:11434/api/embed failed`
- **THEN** the result is `error calling http://<masked-user>@example.com:11434/api/embed failed`

#### Scenario: A harmless user name
- **WHEN** secret masking processes `ssh://git@example.com/org/repo.git`
- **THEN** the result is `ssh://<masked-user>@example.com/org/repo.git`

#### Scenario: A user name in a URL with an IPv6 host, a query and a fragment
- **WHEN** secret masking processes `http://tok123@[2001:db8::1]:11434/x?a=b#c`
- **THEN** the result is `http://<masked-user>@[2001:db8::1]:11434/x?a=b#c`

#### Scenario: One URI with a user name and no password
- **WHEN** `mask_uri_credentials` processes `https://tok_0123456789abcdef@example.com:11434/api/embed`
- **THEN** the result is `https://***@example.com:11434/api/embed`

### Requirement: A URL with no user name stays as written in a text
`redact_text` and `mask_secrets` SHALL leave a URL as written when it has no userinfo, when its user name is empty and it has no password, or when its only `@` is after the authority. `mask_uri_credentials` SHALL fail closed for a text that has an `@` but no userinfo, and SHALL return `<masked-url>`.

#### Scenario: An at sign in a query in a text
- **WHEN** `redact_text` processes `http://example.com:11434/p?email=a@example.com`
- **THEN** the text comes back unchanged

#### Scenario: An empty user name in a text
- **WHEN** `redact_text` processes `http://@example.com/x`
- **THEN** the text comes back unchanged

#### Scenario: An at sign and no userinfo in one URI
- **WHEN** `mask_uri_credentials` processes `http://@example.com/x` or `http://example.com:11434/p?email=a@example.com`
- **THEN** the result is `<masked-url>`

### Requirement: A URL with a user name and a password keeps its masking
The secret masking system SHALL mask both the user name and the password of a URL that has both. `redact_text` SHALL show `<masked-user>:<masked-password>`. `mask_uri_credentials` SHALL keep the user name and show `user:***`. A password with no user name SHALL become `***` in `mask_uri_credentials`.

#### Scenario: A user name and a password in a text
- **WHEN** secret masking processes `http://user:pw12345@example.com/x`
- **THEN** the result is `http://<masked-user>:<masked-password>@example.com/x`

#### Scenario: One URI with a user name and a password
- **WHEN** `mask_uri_credentials` processes `https://user:secret123@example.com:8443/api`
- **THEN** the result is `https://user:***@example.com:8443/api`

#### Scenario: One URI with a password and no user name
- **WHEN** `mask_uri_credentials` processes `http://:supersecretpass@example.com:8080/data`
- **THEN** the result is `http://***@example.com:8080/data`
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Tasks

## 1. The user name of a URL with no password is masked

Kind: behavior. Reads: `src/devops_cli/security/sanitizer.py`, `tests/security/test_secret_redaction.py`. Changes: `src/devops_cli/security/sanitizer.py`, `tests/security/test_secret_redaction.py`, `tests/security/test_redaction_properties.py`, `tests/ai/client/test_url_credential_masking.py`, `tests/k8s/test_gitea_doctor.py`.

- [x] 1.1 In `tests/security/test_secret_redaction.py`, add a test that `redact_text` masks the user name of a URL with no password, in data and code syntax. Cover a token, a harmless name, JSON text, a URL inside a query, a user and a password, an `@` after the authority and an empty user name. Change the `user-only` case of the `mask_uri_credentials` test to the new rule. In `tests/security/test_redaction_properties.py`, add a property for any scheme and host. In `tests/ai/client/test_url_credential_masking.py`, change the cases that held the old behavior and add an IPv6 case. In `tests/k8s/test_gitea_doctor.py`, change the expected message of the `ssh://git@` case to show `<masked-user>`
- Verification: `uv run devops ci test tests/security/test_secret_redaction.py tests/security/test_redaction_properties.py tests/ai/client/test_url_credential_masking.py` fails on the base
- [x] 1.2 In `_mask_url_candidate`, mask a user name with no password as `<masked-user>`. In `mask_uri_credentials`, mask a user name with no password as `***`. Use the parser results that the functions read today, and add no pattern
- Verification: `uv run devops ci test tests/security tests/ai/client/test_url_credential_masking.py tests/invariants/test_architectural_invariants.py`
- [x] 1.3 State the rule in the module docstring of `sanitizer.py`, and point to it from the docstrings of `redact_text` and `mask_uri_credentials`
- Verification: `git grep -n "masked-user" src/devops_cli/security/sanitizer.py`

## 2. Archive

Kind: documentation. Reads: none. Changes: `openspec/changes/`, `openspec/specs/`.

- [x] 2.1 Archive the change
- Verification: `uv run devops openspec archive 2662-mask-url-user-name-without-password`

## Workflow follow-up

- Pending a person: review the diff in tuicr (tuicr -r origin/main..HEAD).
49 changes: 49 additions & 0 deletions openspec/specs/secret-masking/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,52 @@ The secret masking system SHALL mask the value of an assignment even when the va
#### Scenario: An assignment operator
- **WHEN** secret masking processes `password:=correcthorse`, `token:=abcdefg` or `token: =correcthorse`
- **THEN** the result is `password=<masked-password>` or `token=<masked-token>`

### Requirement: The user name of a URL is masked when the URL has no password
The secret masking system SHALL mask the user name of a URL that has a user name and no password, whatever the scheme. `redact_text` and `mask_secrets` SHALL show `<masked-user>` in place of the user name, in data and code syntax. `mask_uri_credentials` SHALL show `***`. The scheme, the host, the port, the path, the query and the fragment SHALL stay as written.

#### Scenario: A token used as the user name in a text
- **WHEN** secret masking processes `error calling http://tok_0123456789abcdef@example.com:11434/api/embed failed`
- **THEN** the result is `error calling http://<masked-user>@example.com:11434/api/embed failed`

#### Scenario: A harmless user name
- **WHEN** secret masking processes `ssh://git@example.com/org/repo.git`
- **THEN** the result is `ssh://<masked-user>@example.com/org/repo.git`

#### Scenario: A user name in a URL with an IPv6 host, a query and a fragment
- **WHEN** secret masking processes `http://tok123@[2001:db8::1]:11434/x?a=b#c`
- **THEN** the result is `http://<masked-user>@[2001:db8::1]:11434/x?a=b#c`

#### Scenario: One URI with a user name and no password
- **WHEN** `mask_uri_credentials` processes `https://tok_0123456789abcdef@example.com:11434/api/embed`
- **THEN** the result is `https://***@example.com:11434/api/embed`

### Requirement: A URL with no user name stays as written in a text
`redact_text` and `mask_secrets` SHALL leave a URL as written when it has no userinfo, when its user name is empty and it has no password, or when its only `@` is after the authority. `mask_uri_credentials` SHALL fail closed for a text that has an `@` but no userinfo, and SHALL return `<masked-url>`.

#### Scenario: An at sign in a query in a text
- **WHEN** `redact_text` processes `http://example.com:11434/p?email=a@example.com`
- **THEN** the text comes back unchanged

#### Scenario: An empty user name in a text
- **WHEN** `redact_text` processes `http://@example.com/x`
- **THEN** the text comes back unchanged

#### Scenario: An at sign and no userinfo in one URI
- **WHEN** `mask_uri_credentials` processes `http://@example.com/x` or `http://example.com:11434/p?email=a@example.com`
- **THEN** the result is `<masked-url>`

### Requirement: A URL with a user name and a password keeps its masking
The secret masking system SHALL mask both the user name and the password of a URL that has both. `redact_text` SHALL show `<masked-user>:<masked-password>`. `mask_uri_credentials` SHALL keep the user name and show `user:***`. A password with no user name SHALL become `***` in `mask_uri_credentials`.

#### Scenario: A user name and a password in a text
- **WHEN** secret masking processes `http://user:pw12345@example.com/x`
- **THEN** the result is `http://<masked-user>:<masked-password>@example.com/x`

#### Scenario: One URI with a user name and a password
- **WHEN** `mask_uri_credentials` processes `https://user:secret123@example.com:8443/api`
- **THEN** the result is `https://user:***@example.com:8443/api`

#### Scenario: One URI with a password and no user name
- **WHEN** `mask_uri_credentials` processes `http://:supersecretpass@example.com:8080/data`
- **THEN** the result is `http://***@example.com:8080/data`
Loading
Loading