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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ and versions are tracked in the repo-root `VERSION` file.
- Formalize typed extension callback protocols, entry-point capability metadata,
and pre-load API-version negotiation.

### Security

- Redact recognized secret keys embedded in query strings, comma-separated
values, and header-style `key: value` arguments before they reach logs or
persisted history.

### Added

- Add a framework choice guide, five-minute evaluation path, and clearer
Expand Down
2 changes: 1 addition & 1 deletion docs/security-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The boundaries are intentionally explicit:

| Threat / asset | Framework controls and tests | Residual risk and consumer action |
| --- | --- | --- |
| Secrets in argv, environment-derived values, config, or prompts leak into logs | Sensitive options/arguments, secret-name heuristics, equals/short-option handling, and redaction before history callbacks; `tests/test_redaction_security.py`, `tests/test_app_security_boundaries.py`, and `tests/test_invocation_parity.py` | A custom secret name or consumer log can still disclose data. Mark domain-specific parameters with `sensitive=True`, do not log `ctx.config`, and review custom formatters/history writers. |
| Secrets in argv, environment-derived values, config, or prompts leak into logs | Sensitive options/arguments, secret-name heuristics, embedded query/list/header segment handling, equals/short-option handling, and redaction before history callbacks; `tests/test_redaction_security.py`, `tests/test_app_security_boundaries.py`, and `tests/test_invocation_parity.py` | A custom secret name or consumer log can still disclose data. Mark domain-specific parameters with `sensitive=True`, do not log `ctx.config`, and review custom formatters/history writers. |
| Logs, history, JSON, or run metadata expose credentials or unbounded attacker text | Redacted history boundary, bounded JSON log messages, owner-only POSIX modes, atomic metadata writes, and JSON contract tests | Consumer-owned paths and history stores may have weaker permissions. Set private ACLs, avoid copying raw logs, and treat retained diagnostics as sensitive. |
| Symlink, traversal, replacement, or mount races redirect cleanup | Exclusive runtime-leaf ownership, retained descriptors, identity checks, no-follow traversal, run-ID containment, and fail-closed cleanup; `tests/test_cleanup_security.py`, `tests/test_app_security_boundaries.py`, and adversarial regression tests | A same-account process with the same filesystem authority can race user-owned paths. Use a private cache root and avoid sharing runtime trees between mutually hostile users. |
| Insecure permissions expose runtime files | POSIX `0600`/`0700` modes; Windows uses inherited user-profile ACLs and warns when secure handle operations are unavailable | A custom Windows cache root or network filesystem may not inherit private ACLs. Consumers must provision and verify permissions. |
Expand Down
21 changes: 18 additions & 3 deletions lib/python/base_cli/redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
REDACTED = "[REDACTED]"
SECRET_KEY_RE = re.compile(r"(token|password|secret|api[-_]?key|authorization)", re.IGNORECASE)
URL_CREDENTIALS_RE = re.compile(r"(?P<prefix>[a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@")
_INLINE_SEGMENT_END = r"(?=(?:[&,;]|\s+[A-Za-z][A-Za-z0-9_-]*\s*[=:])|$)"
_INLINE_KEY_VALUE_RE = re.compile(
rf"(?P<key>(?<![A-Za-z0-9_-])[A-Za-z][A-Za-z0-9_-]*)(?P<separator>=)"
rf"(?P<value>[^\n]*?){_INLINE_SEGMENT_END}"
)
_INLINE_COLON_VALUE_RE = re.compile(
rf"(?P<key>(?<![A-Za-z0-9_-])[A-Za-z][A-Za-z0-9_-]*)"
rf"(?P<separator>\s*:(?!//)\s*)(?P<value>[^\n]*?){_INLINE_SEGMENT_END}"
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -593,12 +602,18 @@ def _legacy_short_aliases(sensitive_options: Iterable[str]) -> tuple[str, ...]:


def _redact_inline_text(value: str) -> str:
key, separator, _raw_value = value.partition("=")
if separator and is_secret_key(option_name_to_parameter(key)):
value = f"{key}={REDACTED}"
value = _INLINE_KEY_VALUE_RE.sub(_redact_inline_segment, value)
value = _INLINE_COLON_VALUE_RE.sub(_redact_inline_segment, value)
return redact_text_value(value)


def _redact_inline_segment(match: re.Match[str]) -> str:
key = match.group("key")
if not is_secret_key(option_name_to_parameter(key)):
return match.group(0)
return f"{key}{match.group('separator')}{REDACTED}"


def _is_option_alias(value: str) -> bool:
return bool(_split_option(value)[0])

Expand Down
44 changes: 44 additions & 0 deletions tests/test_redaction_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,50 @@ def test_secret_name_heuristics_apply_without_registration(self) -> None:
self.assertEqual(redact_argv(argv, set()), expected)
self.assertEqual(redact_history_argv(argv, set()), expected)

def test_embedded_secret_segments_are_redacted_without_registration(self) -> None:
cases = (
(
[
"tool",
"fetch",
"--url",
"https://api.example.test/resource?filter=active&token=SUPERSECRET123",
],
[
"tool",
"fetch",
"--url",
"https://api.example.test/resource?filter=active&token=[REDACTED]",
],
),
(
["tool", "run", "--env", "FOO=bar,SECRET_TOKEN=hunter2"],
["tool", "run", "--env", "FOO=bar,SECRET_TOKEN=[REDACTED]"],
),
(
["tool", "call", "-H", "Authorization: Bearer sk-supersecrettoken123"],
["tool", "call", "-H", "Authorization: [REDACTED]"],
),
(
[
"tool",
"call",
"--header",
"X-Api-Key: hunter2,Accept: application/json",
],
[
"tool",
"call",
"--header",
"X-Api-Key: [REDACTED],Accept: application/json",
],
),
)
for argv, expected in cases:
with self.subTest(argv=argv):
self.assertEqual(redact_argv(argv, set()), expected)
self.assertEqual(redact_history_argv(argv, set()), expected)

def test_option_looking_values_follow_click_consumption(self) -> None:
self.assertEqual(
redact_argv(["tool", "--token", "--verbose"], {"token"}),
Expand Down
Loading