diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef7cf0ea2f..c1c14b31ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,12 @@ loopx check \ --scan-path examples/ ``` +The credential rule also rejects unlabeled provider token shapes (GitHub, Slack, +AWS, Google, Stripe, npm, PyPI, JWT) anywhere in a file. A fixture or example +that must demonstrate a redaction keeps the value split, for example +`"ghp_" + ""` in Python or a `\u` escape in JSON, so the test still +exercises the scan without a raw token entering the repository or the PR diff. + ## Local Development Use the [developer guide](docs/development/README.md) as the stable entry point. diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index b4cd405408..71dfd648c6 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -31,6 +31,10 @@ GOAL_ID = "artifact-lifecycle-fixture" +# Split so the literal provider shape is not itself a credential hit for the +# public boundary scan, the same convention the source fixtures follow. +PROVIDER_TOKEN = "ghp_" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345" + # Substrings that must never appear in a public projection. FORBIDDEN_LEAKS = ( "/Users/", @@ -290,7 +294,7 @@ def assert_private_values_are_redacted_or_dropped() -> None: "delivery_batch_scale": "multi_surface", "evidence_ref": "/Users/private-owner/.ssh/id_rsa", "recommended_action": ( - "publish ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345 and " + f"publish {PROVIDER_TOKEN} and " "see /private/var/folders/secret/notes.md" ), } @@ -301,14 +305,14 @@ def assert_private_values_are_redacted_or_dropped() -> None: for leaked in ( "/Users/private-owner", "/private/var/folders", - "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + PROVIDER_TOKEN, ): assert leaked not in rendered, (leaked, rendered) # Unsafe references are withheld by the same validator as other status # projections; no local path is retained even as a truncated fragment. assert _compact_text("/Users/private-owner/notes.md") is None - assert _compact_text("token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345") is None + assert _compact_text(f"token {PROVIDER_TOKEN}") is None assert _compact_text("/private/var/folders/x/secret.md") is None diff --git a/loopx/contract.py b/loopx/contract.py index f3f01f1fd2..117841bbb2 100644 --- a/loopx/contract.py +++ b/loopx/contract.py @@ -42,6 +42,7 @@ collect_history, ) from .paths import DEFAULT_RUNTIME_ROOT, LEGACY_RUNTIME_ROOT, rel_or_abs, resolve_runtime_root +from .public_safe_text import PROVIDER_TOKEN_SHAPE_PATTERN_SOURCE from .registry import inspect_registry, inspect_registry_boundary, registry_goals, resolve_state_file from .state_projection import state_projection_gap_warning from .control_plane.todos.contract import ( @@ -111,6 +112,13 @@ def _prefilter_fold(text: str) -> str: r"(? str: "tok" + "en=", "pass" + "word=", "author" + "ization:", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", + "github_pat_", + "akia", + "asia", + "xox", + "aiza", + "sk_live_", + "sk_test_", + "rk_live_", + "rk_test_", + "npm_", + "pypi-", + "eyj", ), ), # The private roots take the host's own separator, so a Windows host @@ -204,6 +229,11 @@ def _prefilter_fold(text: str) -> str: r"<[^>]{1,64}>|\{\{|example|dummy|placeholder|changeme|redacted|your[-_]", re.I, ) +# A provider token has no keyword that separates its value from surrounding +# text, so only the exact access-key id published in the AWS documentation may +# be treated as an example. A placeholder word inside an opaque token body is +# part of the value, not an external reference (Refs #7713 review). +CREDENTIAL_DOCUMENTED_EXAMPLE_PATTERN = re.compile(r"AKIAIOSFODNN7EXAMPLE") CREDENTIAL_HIGH_ENTROPY_VALUE_PATTERN = re.compile(r"[A-Za-z0-9._-]{20,}") CREDENTIAL_VARIABLE_REFERENCE_PATTERN = re.compile( r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\s*[,)\]}]" @@ -225,7 +255,7 @@ def _credential_match_is_reference(line: str, match: re.Match[str]) -> bool: last_keyword = keyword.group(0) value = value[keyword.end() :].lstrip(" \t\"'`") if last_keyword is None: - return bool(CREDENTIAL_PLACEHOLDER_PATTERN.search(match.group(0))) + return bool(CREDENTIAL_DOCUMENTED_EXAMPLE_PATTERN.fullmatch(match.group(0))) if not value: return True if CREDENTIAL_ENVIRONMENT_REFERENCE_PATTERN.match(value): diff --git a/tests/fixtures/control_plane/reliability_diagnostics_public_safety_v0.json b/tests/fixtures/control_plane/reliability_diagnostics_public_safety_v0.json index e37be4f885..3702d79305 100644 --- a/tests/fixtures/control_plane/reliability_diagnostics_public_safety_v0.json +++ b/tests/fixtures/control_plane/reliability_diagnostics_public_safety_v0.json @@ -2,8 +2,8 @@ "schema_version": "reliability_diagnostics_public_safety_counterfactuals_v0", "unsafe_summary_tokens": [ "sk-abcdefghijklmnop0123", - "ghp_abcdefghijklmnopqrstuvwx", - "eyJabcdefghijk.abcdefghijk.abcdefghijk", + "ghp_\u0061bcdefghijklmnopqrstuvwx", + "eyJ\u0061bcdefghijk.abcdefghijk.abcdefghijk", "C:/Users/alice/private.txt", "path:/Users/alice/private.txt", "file:///Users/alice/private.txt", @@ -11,8 +11,8 @@ ], "unsafe_identity_tokens": [ "sk-abcdefghijklmnop0123", - "ghp_abcdefghijklmnopqrstuvwx", - "eyJabcdefghijk.abcdefghijk.abcdefghijk" + "ghp_\u0061bcdefghijklmnopqrstuvwx", + "eyJ\u0061bcdefghijk.abcdefghijk.abcdefghijk" ], "invalid_source_ref_tokens": [ "C:/Users/alice/private.txt" diff --git a/tests/test_chat_activity.py b/tests/test_chat_activity.py index e93e069059..01ce39bd5f 100644 --- a/tests/test_chat_activity.py +++ b/tests/test_chat_activity.py @@ -30,6 +30,11 @@ def _command(command: str, actions: list[dict] | None = None, **extra) -> dict: "commandActions": actions or [{"type": "unknown", "command": command}], **extra} +# Split so the literal provider shape is not itself a credential hit for the +# public boundary scan, the same convention the source fixtures follow. +GITHUB_TOKEN = "ghp_" + "abcdefghijklmnop123" + + def test_command_step_names_the_intent_and_keeps_the_full_command_behind_details(): steps = CodexActivitySteps(protected_paths=["/work/project"]) item = _command( @@ -67,9 +72,9 @@ def test_failed_items_are_failed_steps_not_completions(): def test_credentials_in_commands_are_masked_before_replay(): title = CodexActivitySteps().started(_command( "curl -H 'Authorization: Bearer abc.def' --api-key=k123 https://x.test?q=1 " - "&& GH_TOKEN=ghp_abcdefghijklmnop123 gh pr list --password hunter2 sk-proj-abcdefghijklmnop" + f"&& GH_TOKEN={GITHUB_TOKEN} gh pr list --password hunter2 sk-proj-abcdefghijklmnop" ))["title"] - for secret in ("abc.def", "k123", "ghp_abcdefghijklmnop123", "hunter2", "sk-proj-abcdefghijklmnop"): + for secret in ("abc.def", "k123", GITHUB_TOKEN, "hunter2", "sk-proj-abcdefghijklmnop"): assert secret not in title assert "gh pr list" in title and "https://x.test" in title diff --git a/tests/test_contract_credential_pattern_precision.py b/tests/test_contract_credential_pattern_precision.py index 6315a775e3..7e229a9645 100644 --- a/tests/test_contract_credential_pattern_precision.py +++ b/tests/test_contract_credential_pattern_precision.py @@ -1,5 +1,9 @@ from __future__ import annotations +import json +import os +import subprocess +import sys from pathlib import Path import pytest @@ -14,6 +18,7 @@ PASSWORD_ASSIGN = "pass" + "word=" REAL_BEARER_VALUE = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90" PRIVATE_LARK_TENANT_HOST = "tenant.lark" + "office.com" +ROOT = Path(__file__).resolve().parents[1] def _scan_line(tmp_path: Path, line: str) -> dict[str, list[str]]: @@ -76,7 +81,7 @@ def test_literal_credential_hits_stay_errors(tmp_path: Path, line: str) -> None: def test_a_literal_on_a_line_with_references_still_fails(tmp_path: Path) -> None: - line = f'{PASSWORD_ASSIGN}$SERVICE_SECRET {TOKEN_ASSIGN}{REAL_BEARER_VALUE}' + line = f"{PASSWORD_ASSIGN}$SERVICE_SECRET {TOKEN_ASSIGN}{REAL_BEARER_VALUE}" payload = _scan_line(tmp_path, line) @@ -128,3 +133,109 @@ def test_private_lark_tenant_urls_remain_blocked(tmp_path: Path, line: str) -> N assert len(payload["hits"]) == 1 assert payload["hits"][0].endswith(": private_doc_url") + + +# A provider token carries its issuer in the prefix, so no label says what it +# is. The shapes come from the canonical public-safety owner instead of a +# second list here: this rule gates repository publication, and a list that +# recognized only some issuers certified the rest as public-safe. +PROVIDER_TOKEN_SAMPLES = [ + "ghp_" + "16C7e42F292c6912E7710c838347Ae178B4a", + "github_pat_" + "11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz0123456789", + "xoxb-" + "123456789012-abcdefghijkl", + "sk_live_" + "abcdefghijklmnopqrst", + "npm_" + "abcdefghijklmnopqrstuvwxyz", + "pypi-" + "abcdefghijklmnopqrst", + "AIza" + "SyD-1234567890abcdefghijklmnopqrstu", + "eyJ" + "hbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.dozjgNryP4J3jVmNHl0w5N_XgL0n3", +] + + +@pytest.mark.parametrize("token", PROVIDER_TOKEN_SAMPLES) +def test_unlabeled_provider_tokens_are_credential_errors( + tmp_path: Path, + token: str, +) -> None: + payload = _scan_line(tmp_path, f"token {token}") + + assert len(payload["hits"]) == 1 + assert payload["hits"][0].endswith(": credential") + assert payload["credential_reference_hits"] == [] + + +def test_credential_rule_reuses_the_canonical_provider_shape_owner() -> None: + from loopx.public_safe_text import PROVIDER_TOKEN_SHAPE_PATTERN_SOURCE + + assert ( + PROVIDER_TOKEN_SHAPE_PATTERN_SOURCE + in contract.LEAK_RULES["credential"].pattern.pattern + ) + + +def test_a_short_provider_lookalike_stays_clean(tmp_path: Path) -> None: + payload = _scan_line(tmp_path, "token gh_preview, not a credential") + + assert payload["hits"] == [] + assert payload["credential_reference_hits"] == [] + + +PROVIDER_TOKENS_WITH_PLACEHOLDER_BODY = [ + "ghp_" + "dummy" + "1234567890abcdefghijklmnop", + "npm_" + "example" + "1234567890abcdefghij", +] + + +@pytest.mark.parametrize("token", PROVIDER_TOKENS_WITH_PLACEHOLDER_BODY) +def test_provider_placeholder_words_inside_the_body_stay_literal_errors( + tmp_path: Path, + token: str, +) -> None: + payload = _scan_line(tmp_path, f"token {token}") + + assert payload["hits"] == ["sample.md:1: credential"] + assert payload["credential_reference_hits"] == [] + + +def _run_check_cli(tmp_path: Path, line: str) -> tuple[int, dict[str, object]]: + outgoing = tmp_path / "outgoing.md" + outgoing.write_text(f"{line}\n", encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--format", + "json", + "--registry", + str(tmp_path / "registry.json"), + "--runtime-root", + str(tmp_path / "runtime"), + "check", + "--scan-path", + str(outgoing), + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + check=False, + ) + assert result.stdout, result.stderr + return result.returncode, json.loads(result.stdout) + + +@pytest.mark.parametrize("token", PROVIDER_TOKENS_WITH_PLACEHOLDER_BODY) +def test_real_check_cli_rejects_provider_placeholder_words_inside_the_body( + tmp_path: Path, + token: str, +) -> None: + returncode, payload = _run_check_cli(tmp_path, f"token {token}") + + assert returncode == 1 + assert payload["ok"] is False + assert payload["public_boundary_scan"]["ok"] is False + assert any( + row["code"] == "public_boundary_violation" + for row in payload["error_diagnostics"] + ) diff --git a/tests/test_contract_public_boundary_prefilter.py b/tests/test_contract_public_boundary_prefilter.py index 5682f0510b..02c3e9ec4b 100644 --- a/tests/test_contract_public_boundary_prefilter.py +++ b/tests/test_contract_public_boundary_prefilter.py @@ -68,6 +68,22 @@ def test_explicit_scan_file_keeps_unsupported_suffix(tmp_path: Path) -> None: ("credential", "tok" + "en=literal"), ("credential", "pass" + "word=literal"), ("credential", "Author" + "ization: literal"), + # Provider tokens carry their issuer in the prefix instead of a label. + # Every shape the canonical owner matches needs a prefilter literal, or + # the rule is silently skipped for that whole issuer family. + ("credential", "ghp_" + "16C7e42F292c6912E7710c838347Ae178B4a"), + ("credential", "ghs_" + "16C7e42F292c6912E7710c838347Ae178B4a"), + ("credential", "github_pat_" + "11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz0123456789"), + ("credential", "AS" + "IA1234567890ABCDEF"), + ("credential", "xoxb-" + "123456789012-abcdefghijkl"), + ("credential", "AIza" + "SyD-1234567890abcdefghijklmnopqrstu"), + ("credential", "sk_live_" + "abcdefghijklmnopqrst"), + ("credential", "sk_test_" + "abcdefghijklmnopqrst"), + ("credential", "rk_live_" + "abcdefghijklmnopqrst"), + ("credential", "rk_test_" + "abcdefghijklmnopqrst"), + ("credential", "npm_" + "abcdefghijklmnopqrstuvwxyz"), + ("credential", "pypi-" + "abcdefghijklmnopqrst"), + ("credential", "eyJ" + "hbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.dozjgNryP4J3jVmNHl0w5N_XgL0n3"), ("local_private_path", "/" + "Users/alice/Documents/example.md"), ("local_private_path", "/" + "Users/alice/code-reading/example.md"), ("local_private_path", "/ext" + "_data/example.md"),