Skip to content
Open
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
11 changes: 8 additions & 3 deletions docs/json-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,14 @@ errors and diagnostics still use the normal stderr and exit-code boundary.

The lower-level `success_envelope()`, `error_envelope()`, `dumps_envelope()`,
and `redact_json_value()` helpers are public for commands that need to publish
their own structured `details` records. Secret-looking keys (`token`,
`password`, `secret`, `api_key`, and `authorization`) and credential-bearing
URLs are redacted recursively.
their own structured `details` records. Secret-looking keys and
credential-bearing URLs are redacted recursively. The heuristic covers
`token`, `password`, `passwd`, `pwd`, `passphrase`, `secret`, `credential`,
`private-key`, `access-key`, `api-key`, `authorization`, `bearer`, `session`,
`cookie`, `signature`, `otp`, `salt`, `sas`, and `pem`, including camelCase
forms such as `accessToken` and `clientSecret`. A generic `key` name, including
`key-file` and `public-key`, is not treated as secret by itself; explicit
`sensitive=True` remains the authoritative control for domain-specific names.

Golden payloads for each public contract live in
[`tests/fixtures/contracts`](https://github.com/basefoundry/base-cli/tree/main/tests/fixtures/contracts).
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, 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. |
| Secrets in argv, environment-derived values, config, or prompts leak into logs | Sensitive options/arguments, shared secret-name heuristics (including password/passwd/pwd/passphrase, credential, private/access/API key, camelCase access/refresh/id token and client/auth secret forms, bearer/session/cookie, OTP, salt, SAS, and PEM names), 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
6 changes: 3 additions & 3 deletions lib/python/base_cli/json_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from logging import LogRecord
from typing import Any

from .redaction import REDACTED, redact_text_value
from .redaction import REDACTED, SECRET_KEY_PATTERN, is_secret_key, redact_text_value

JSON_CONTRACT_VERSION = 1
JSON_LOG_SCHEMA = "base-cli.log"
Expand All @@ -29,7 +29,7 @@
r"|\s+[A-Za-z][A-Za-z0-9_-]*\s*[=:])|\s|$)"
)
_SENSITIVE_ASSIGNMENT = re.compile(
r"(?i)(\b(?:token|password|secret|api[-_]?key|authorization)\b\s*[:=]\s*)"
rf"(?i)({SECRET_KEY_PATTERN}\s*[:=]\s*)"
rf"(\S+?){_SENSITIVE_ASSIGNMENT_BOUNDARY}"
)

Expand Down Expand Up @@ -161,4 +161,4 @@ def _safe_text(value: str) -> str:


def _is_sensitive_key(value: str) -> bool:
return re.search(r"(?i)(token|password|secret|api[-_]?key|authorization)", value) is not None
return is_secret_key(value)
8 changes: 7 additions & 1 deletion lib/python/base_cli/redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
from typing import Any

REDACTED = "[REDACTED]"
SECRET_KEY_RE = re.compile(r"(token|password|secret|api[-_]?key|authorization)", re.IGNORECASE)
SECRET_KEY_PATTERN = (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security regression (verified): unifying the secret-name regexes replaces the old unanchored substring match with (?<![A-Za-z0-9])...(?![A-Za-z0-9])-anchored alternatives, which silently narrows coverage for single-word alternatives (token, password, secret, credential, authorization, bearer, session, cookie, signature, otp, salt, sas, pem) whenever they're concatenated with another word and no -/_ separator. Verified directly: accessToken, refreshToken, idToken, clientSecret, and authToken all matched the old bare-substring SECRET_KEY_RE but do not match the new pattern (only the three compound alternatives that bake in an optional separator — private[-_]?key, access[-_]?key, api[-_]?key — still match their concatenated forms). This reaches all three real call sites of is_secret_key (JSON details redaction, CLI parameter auto-detection, inline log-text redaction), none of which normalize camelCase before testing, and it's untested by this PR's own new cases (which only cover hyphen/underscore-separated forms). Realistic OAuth/JS-SDK-style keys like accessToken or clientSecret would now be logged/output in plaintext where they were previously redacted.

r"(?<![A-Za-z0-9])(?:access[-_]?token|refresh[-_]?token|id[-_]?token|"
r"client[-_]?secret|auth[-_]?token|token|password|passwd|pwd|passphrase|secret|credential|"
r"private[-_]?key|access[-_]?key|api[-_]?key|authorization|bearer|session|"
r"cookie|signature|otp|salt|sas|pem)(?![A-Za-z0-9])"
)
SECRET_KEY_RE = re.compile(SECRET_KEY_PATTERN, re.IGNORECASE)
URL_CREDENTIALS_RE = re.compile(r"(?P<prefix>[a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@")
# Punctuation is part of a value unless it is immediately followed by another
# assignment segment. This prevents ``PASSWORD=abc,def`` from exposing ``def``
Expand Down
16 changes: 16 additions & 0 deletions tests/test_json_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ def test_envelopes_have_stable_fields_and_recursive_redaction(self) -> None:
self.assertEqual(failure["message"], "authorization=[REDACTED]")
self.assertEqual(json.loads(base_cli.dumps_envelope(failure)), failure)

def test_json_redaction_uses_extended_secret_key_heuristics(self) -> None:
envelope = base_cli.success_envelope(
run_id=None,
details={
"private_key": "private",
"session_cookie": "cookie",
"accessToken": "camel-case-secret",
"label": "visible",
},
)

self.assertEqual(envelope["details"]["private_key"], "[REDACTED]")
self.assertEqual(envelope["details"]["session_cookie"], "[REDACTED]")
self.assertEqual(envelope["details"]["accessToken"], "[REDACTED]")
self.assertEqual(envelope["details"]["label"], "visible")

def test_json_contract_emitters_reject_nested_non_finite_values(self) -> None:
invalid = {"nested": [{"value": float("inf")}]}
envelope = base_cli.success_envelope(run_id=None, details=invalid)
Expand Down
35 changes: 35 additions & 0 deletions tests/test_redaction_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,37 @@ 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_extended_secret_name_heuristics_apply_consistently(self) -> None:
cases = (
("--passwd", "old-password"),
("--passphrase", "phrase"),
("--credential", "credential-value"),
("--private-key", "private-key-value"),
("--access_key", "access-key-value"),
("--accessToken", "access-token-value"),
("--refreshToken", "refresh-token-value"),
("--idToken", "id-token-value"),
("--clientSecret", "client-secret-value"),
("--authToken", "auth-token-value"),
("--bearer", "bearer-value"),
("--session-cookie", "cookie-value"),
("--signature", "signature-value"),
("--otp", "123456"),
("--salt", "salt-value"),
("--pem", "pem-value"),
)
for option, value in cases:
with self.subTest(option=option):
argv = ["tool", option, value]
expected = ["tool", option, REDACTED]
self.assertEqual(redact_argv(argv, set()), expected)
self.assertEqual(redact_history_argv(argv, set()), expected)

def test_bare_key_is_not_treated_as_a_secret_name(self) -> None:
self.assertEqual(redact_argv(["tool", "--key", "visible"], set()), ["tool", "--key", "visible"])
self.assertEqual(redact_argv(["tool", "--key-file", "visible"], set()), ["tool", "--key-file", "visible"])
self.assertEqual(redact_argv(["tool", "--public-key", "visible"], set()), ["tool", "--public-key", "visible"])

def test_embedded_secret_segments_are_redacted_without_registration(self) -> None:
cases = (
(
Expand Down Expand Up @@ -140,6 +171,10 @@ def test_embedded_secret_segments_are_redacted_without_registration(self) -> Non
["tool", "PASSWORD=abc,def&LABEL=visible"],
["tool", f"PASSWORD={REDACTED}&LABEL=visible"],
),
(
["tool", "accessToken=camel-case-secret"],
["tool", f"accessToken={REDACTED}"],
),
)
for argv, expected in cases:
with self.subTest(argv=argv):
Expand Down
Loading