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
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,9 @@ it links:
`SkipCertificateValidation` (Docker instances) and `AllowInsecureHttp`
(Portainer) exist for trusted/self-signed/local setups; how each is
validated and reported is in [Docker instances](areas/docker-instances.md)
and [Container actions](areas/container-actions.md).
and [Container actions](areas/container-actions.md). URLs with user info
are logged, stored and shown with it masked as `***`
([Telemetry](areas/telemetry.md), [record 0005](decisions/0005-urls-are-output-with-user-info-masked.md)).
- **Pagination stays on its origin.** `DockerHubClient`, `OciRegistryClient`
and `DockerScoutVulnerabilityProvider` follow a next-page link only on the
origin of the listing (`Infrastructure\PaginationLinkGuard`), so a remote
Expand Down
9 changes: 5 additions & 4 deletions docs/areas/container-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ transport security; the UI is the intended caller of the actions.
- A Docker instance has at most one Portainer endpoint. It is synchronized together with the instance
([Docker instances](docker-instances.md)):
- when Portainer is enabled, the stored endpoint is created or updated with the name `<instance name> Portainer`,
the trimmed `BaseUrl` and the configured `EndpointId`, and marked enabled;
- when Portainer is disabled, an existing stored endpoint is kept but marked disabled;
the trimmed `BaseUrl` (its user info masked as `***`) and the configured `EndpointId`, and marked enabled;
- when Portainer is disabled, an existing stored endpoint is kept but marked disabled, and its stored base URL is rewritten with the user info
masked;
- when the Docker instance is removed from configuration, its Portainer endpoint and action runs are deleted with it.
- Credentials (`Username`, `Password`, `ApiToken`) are never stored in the database; they are read from configuration
at the time of each call.
Expand All @@ -39,7 +40,7 @@ transport security; the UI is the intended caller of the actions.
- Otherwise `Username` and `Password` are posted to Portainer's authentication endpoint and the returned JWT is used as
a bearer token. The login is repeated for every operation; tokens are not cached.
- A login that answers non-success, or answers success without a token, ends the operation with an authentication
error that names the Portainer base URL and the HTTP status code; credentials never appear in it or in the log.
error that names the Portainer base URL (user info masked as `***`) and the HTTP status code; credentials never appear in it or in the log.

### Capability check

Expand Down Expand Up @@ -85,7 +86,7 @@ transport security; the UI is the intended caller of the actions.
absolute `http`/`https` URI; `BaseUrl` is `http` without `AllowInsecureHttp: true`; neither `ApiToken` nor both
`Username` and `Password` are set; `RequestTimeoutSeconds` is outside 1–300.
- A disabled Portainer section is not validated at all; its values may be incomplete.
- Error messages and log entries name the Docker instance, the Portainer base URL, the action, the container name and
- Error messages and log entries name the Docker instance, the Portainer base URL (user info masked as `***`), the action, the container name and
HTTP status codes. They never contain the password, the API token or the JWT.

### Known gaps
Expand Down
4 changes: 3 additions & 1 deletion docs/areas/docker-instances.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ on the snapshots and samples it produces.
`DockerUpdateGuard:Scanning:DiscoveryIntervalMinutes` tick, and at the start of every runtime container scan and
every resource-statistics collection (schedules: [Scan engine](scan-engine.md)). A synchronization:
- creates a record for each new name, with registration source *configuration file*;
- updates endpoint URI (trimmed `BaseUrl`), connection kind, enabled flag and `SkipCertificateValidation` of each
- updates endpoint URI (trimmed `BaseUrl`, its user info masked as `***`), connection kind, enabled flag and `SkipCertificateValidation` of each
existing one;
- **deletes** every stored instance whose name is no longer configured, immediately and without waiting for
retention, together with its scan runs, container snapshots, resource samples, Portainer endpoint and action
Expand Down Expand Up @@ -147,6 +147,8 @@ on the snapshots and samples it produces.
- A non-success Engine answer is reported as *failed* with the instance name, the HTTP status code and the response
body. Unexpected errors are reported with the instance name and the error message. These messages can end up in a
scan run's error message and are shown in the UI.
- A message or log entry that names an endpoint shows its URL with the user info replaced by `***`; connecting still
uses the configured URL.
- A resource collection that fails for an instance (list request failed or timed out, or any stats request timed out
or broke) records **no** samples for that instance in that round and logs a warning; other instances are not
affected.
Expand Down
2 changes: 1 addition & 1 deletion docs/areas/registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ retry policy and the failure model.
of that value, the total again capped at 30 seconds.
- Every method is retried, including requests with a body (the body is buffered so it can be resent).
- When the retries are used up the last response is returned to the caller, or the last error is raised.
- Each retry is logged as a warning with the method, the URL without its query, the status or error, the attempt
- Each retry is logged as a warning with the method, the URL without its user info and query, the status or error, the attempt
number and the wait.
- `DockerUpdateGuard:DockerHub:RequestTimeoutSeconds` (default 30, range 1–300) is the total time one request may
take including all retries and waits, for Docker Hub, other registries and the release feeds. A request that runs
Expand Down
13 changes: 7 additions & 6 deletions docs/areas/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,12 @@ Every exported signal carries the same resource:
nor the database connection string. Credentials travel only in request headers, and headers are not recorded.
- Database statements are logged without parameter values.
- What logs and spans may carry about external systems: configured instance names, image references, configured base
URLs, the Docker Hub account name, HTTP methods and status codes, the scheme, host and port of a rejected
next-page link, counts, durations and error messages from external tools. Retry log lines carry the request URL without its query string.
URLs with any user info replaced by `***`, the Docker Hub account name, HTTP methods and status codes, the scheme,
host and port of a rejected next-page link, counts, durations and error messages from external tools. Retry log
lines carry the request URL without its user info and query string.
- HTTP client spans (`url.full`) and the HTTP client log lines carry scheme, host, port and path only, with query
values as `*`, unless the platform's URI redaction is switched off
(`DOTNET_SYSTEM_NET_HTTP_DISABLEURIREDACTION`). Decision: [0005](../decisions/0005-urls-are-output-with-user-info-masked.md).

## Limits and errors

Expand All @@ -123,13 +127,10 @@ Every exported signal carries the same resource:
- `dockerupdateguard.runtime_containers` counts every container that still has a stored snapshot, including
containers that have since stopped or been removed, until cleanup deletes their snapshots; it does not count only
the currently known containers. Tracked in [#294](https://github.com/LarsLaskowski/DockerUpdateGuard/issues/294).
- Configured base URLs (Docker instance, Portainer) are logged verbatim, and retry log lines keep the user-info part
of a URL, so credentials embedded in a URL (`https://user:secret@host`) reach the logs.
Tracked in [#295](https://github.com/LarsLaskowski/DockerUpdateGuard/issues/295).

## Related decisions

None yet.
- [0005](../decisions/0005-urls-are-output-with-user-info-masked.md) β€” URLs are output with their user info masked.

## Not here

Expand Down
7 changes: 3 additions & 4 deletions docs/areas/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ never their values.
- The UI never shows configured secrets: the Docker Hub token, Portainer credentials and token and the database
connection string are neither displayed nor sent to the browser. Pages show only whether an integration is
configured, and the Docker Hub user name as the title of the My Images list.
- Docker instance endpoints are shown as configured (see *Known gaps*).
- Docker instance endpoints are shown with the user info of their URL replaced by `***`
(`https://***@docker.example:2376/`), also for rows stored before the masking existed.

## Limits and errors

Expand All @@ -139,15 +140,13 @@ never their values.
- A detail route with a well-formed identifier that does not exist (for example a deleted observed image) keeps
showing the loading skeleton instead of a not-found state.
Tracked in [#40](https://github.com/LarsLaskowski/DockerUpdateGuard/issues/40).
- Docker instance endpoints are not masked: an endpoint that embeds credentials as user information in its URL is
shown with them. Tracked in [#295](https://github.com/LarsLaskowski/DockerUpdateGuard/issues/295).
- The Scan History and dashboard descriptions mention discovery and cleanup runs, although those jobs record no scan
runs ([Scan engine](scan-engine.md)).
Tracked in [#278](https://github.com/LarsLaskowski/DockerUpdateGuard/issues/278).

## Related decisions

None yet.
- [0005](../decisions/0005-urls-are-output-with-user-info-masked.md) β€” URLs are output with their user info masked.

## Not here

Expand Down
55 changes: 55 additions & 0 deletions docs/decisions/0005-urls-are-output-with-user-info-masked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 0005: URLs are output with their user info masked

- **Status:** Accepted
- **Date:** 2026-10-24
- **Area:** Telemetry
- **Source:** Issue #295
- **Supersedes:** β€”

## Context

Configuration validation accepts Docker instance and Portainer base URLs that carry user info
(`https://user:secret@host`), and the application wrote those URLs as they stand into log messages and their
attributes, into exception and result messages that end up in scan-run errors and on the pages, into the database
and onto the Docker Instances pages; the retry log dropped only the query string. Secrets in configuration must
never be logged, shown in the UI or put into exception messages or telemetry (`.squad/project.md`, *Security
areas*). The clients still have to connect with the URL as configured.

## Options considered

1. **Mask the user info with `***` wherever a URL is output, keep the configured value for connecting** β€” operators
still see that credentials are configured and which host is meant; one helper serves every output site, and the
stored display values are masked on write and again on display.
2. **Drop the user info entirely** β€” equally safe, but the output no longer shows that the configured URL carries
credentials, which hides a configuration the operator may want to move into a proper credential setting.
3. **Reject URLs with user info in configuration validation** β€” removes the problem at its source, but makes
startup fail for configurations that work today; a product decision beyond this bug.
4. **Redact in a log processor or enricher** β€” covers exported logs only: the console logger, exception and result
messages, scan-run errors, the database and the UI would still carry the secret.
5. **Mask only the leading `scheme://userinfo@` with a regular expression, as the issue sketched** β€” a password with
an unescaped `@` (`https://user:p@ss@host`, which the URI parser rejects) would leave the part after the first
`@` visible, and one with a `/` would not be matched at all.
6. **Mask only on display, store the configured URL** β€” keeps the secret at rest in the database.

## Decision

Option 1. A value with exactly one `@` that the platform's URI parser accepts as `scheme://` with non-empty user info
is output rebuilt from its parts with the user info replaced by `***`; every other value that contains `@` is masked
fail-closed from after its `scheme://` (or from its start) up to its last `@`, because the parser ends the authority at
the first `/`, `?` or `#` and so reads a password such as `admin:#secret@host` as a host without user info; text
without `@` is output unchanged. The rules and where they apply are specified in
[Telemetry](../areas/telemetry.md#credentials-and-external-data) and [Web UI](../areas/web-ui.md#secrets).

## Consequences

- No configured URL credential reaches logs, telemetry attributes, messages, scan runs, the database or the UI
through the paths this change covers; the HTTP client spans and the HTTP client factory's own log lines already
omit user info, because the platform builds their URL from scheme, host, port and path.
- A URL with user info is shown in the parser's normalized form (lower-case host, default port dropped, trailing
slash added); a URL without user info is shown exactly as configured.
- A value with an `@` that is not user info (`https://host.example/@scope/pkg` β†’ `https://***@scope/pkg`, or a second
`@` in the path) is over-masked; accepted, it fails closed. Masking an already masked value changes nothing.
- Messages an external tool writes (Trivy's error output) are passed on as they stand; masking free text is not part
of this decision.
- Rows stored before the change are masked on display and rewritten masked by the next synchronization; scan-run
error messages stored before the change are not rewritten.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,5 @@ once in the [area document](../areas/README.md) of the record's area, and the re
| [0002](0002-squash-merge-pull-requests.md) | Squash-merge pull requests | Accepted | 2026-10-03 |
| [0003](0003-squad-working-records-off-main.md) | Squad working records stay off main, and product PRs never change the squad | Accepted | 2026-10-03 |
| [0004](0004-pagination-follows-only-same-origin-links.md) | Pagination follows only same-origin links | Accepted | 2026-10-24 |
| [0005](0005-urls-are-output-with-user-info-masked.md) | URLs are output with their user info masked | Accepted | 2026-10-24 |
<!-- project:end index -->
14 changes: 7 additions & 7 deletions src/DockerUpdateGuard/Docker/DockerInstanceClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ private static string GetNamedPipeName(Uri pipeUri)

if (string.IsNullOrWhiteSpace(pipePath))
{
throw new InvalidOperationException($"The Docker named pipe URI '{pipeUri}' does not contain a pipe name");
throw new InvalidOperationException($"The Docker named pipe URI '{UrlRedaction.RedactUserInfo(pipeUri)}' does not contain a pipe name");
}

return pipePath;
Expand Down Expand Up @@ -980,9 +980,9 @@ public async Task<ExternalOperationResult<IReadOnlyList<RuntimeContainerDescript

if (TryCreateEngineUri(instanceOptions, out var engineUri) == false || engineUri is null)
{
_logger.DockerInstanceEndpointUnsupported(instanceOptions.Name, instanceOptions.BaseUrl);
_logger.DockerInstanceEndpointUnsupported(instanceOptions.Name, UrlRedaction.RedactUserInfo(instanceOptions.BaseUrl));

return ExternalOperationResult<IReadOnlyList<RuntimeContainerDescriptor>>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{instanceOptions.BaseUrl}'");
return ExternalOperationResult<IReadOnlyList<RuntimeContainerDescriptor>>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{UrlRedaction.RedactUserInfo(instanceOptions.BaseUrl)}'");
}

using var activity = DockerUpdateGuardTelemetry.ActivitySource.StartActivity(TelemetryActivityNames.DockerEngineRequest, ActivityKind.Client);
Expand Down Expand Up @@ -1060,7 +1060,7 @@ public async Task<ExternalOperationResult<IReadOnlyList<RuntimeContainerResource

if (TryCreateEngineUri(instanceOptions, out var engineUri) == false || engineUri is null)
{
return ExternalOperationResult<IReadOnlyList<RuntimeContainerResourceDescriptor>>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{instanceOptions.BaseUrl}'");
return ExternalOperationResult<IReadOnlyList<RuntimeContainerResourceDescriptor>>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{UrlRedaction.RedactUserInfo(instanceOptions.BaseUrl)}'");
}

try
Expand Down Expand Up @@ -1149,7 +1149,7 @@ public async Task<ExternalOperationResult<DockerImageInspectData>> InspectImageA

if (TryCreateEngineUri(instanceOptions, out var engineUri) == false || engineUri is null)
{
return ExternalOperationResult<DockerImageInspectData>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{instanceOptions.BaseUrl}'");
return ExternalOperationResult<DockerImageInspectData>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{UrlRedaction.RedactUserInfo(instanceOptions.BaseUrl)}'");
}

try
Expand Down Expand Up @@ -1186,7 +1186,7 @@ public async Task<ExternalOperationResult<IReadOnlyList<DockerImageHistoryEntryD

if (TryCreateEngineUri(instanceOptions, out var engineUri) == false || engineUri is null)
{
return ExternalOperationResult<IReadOnlyList<DockerImageHistoryEntryData>>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{instanceOptions.BaseUrl}'");
return ExternalOperationResult<IReadOnlyList<DockerImageHistoryEntryData>>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{UrlRedaction.RedactUserInfo(instanceOptions.BaseUrl)}'");
}

try
Expand Down Expand Up @@ -1220,7 +1220,7 @@ public async Task<ExternalOperationResult<long>> GetHostMemoryTotalAsync(DockerI

if (TryCreateEngineUri(instanceOptions, out var engineUri) == false || engineUri is null)
{
return ExternalOperationResult<long>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{instanceOptions.BaseUrl}'");
return ExternalOperationResult<long>.Unsupported($"Docker instance '{instanceOptions.Name}' uses an unsupported endpoint '{UrlRedaction.RedactUserInfo(instanceOptions.BaseUrl)}'");
}

try
Expand Down
Loading
Loading