diff --git a/graphify/extract.py b/graphify/extract.py index fd3f31ed9..0a6037d7d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -16,6 +16,7 @@ from pathlib import Path, PurePath from typing import Any, Callable from .cache import load_cached, save_cached +from .security import redact_secrets from .mcp_ingest import extract_mcp_config, is_mcp_config_path from .manifest_ingest import extract_package_manifest, is_package_manifest_path from .resolver_registry import ( @@ -1759,6 +1760,9 @@ def _shorten_rationale_label(text: str, width: int = 80) -> str: an unbroken URL) -- that would emit a content-free label, so fall back to a plain character truncation of the normalized text in that case. """ + # Redact BEFORE shortening: truncating first could cut a token mid-way and + # leave an unredactable prefix in the label. + text = redact_secrets(text) label = textwrap.shorten(text, width=width, placeholder="…") if label in ("", "…"): flat = " ".join(text.split()) diff --git a/graphify/security.py b/graphify/security.py index ab052e954..1b6b80fb5 100644 --- a/graphify/security.py +++ b/graphify/security.py @@ -391,6 +391,37 @@ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]") _MAX_LABEL_LEN = 256 +# Credential shapes redacted from free text that is persisted into the graph +# (rationale/comment labels). Shape-based only, so it is risk reduction rather +# than a guarantee: vendor-prefixed tokens and PEM private-key headers. +_SECRET_SHAPE_RE = re.compile( + r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----" + r"|\b(?:AKIA|ASIA)[0-9A-Z]{16}\b" + r"|\bgh[pousr]_[A-Za-z0-9]{36,}" + r"|\bgithub_pat_[A-Za-z0-9_]{22,}" + r"|\bxox[baprs]-[A-Za-z0-9-]{10,}" + r"|\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}" + r"|\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}" + r"|\bAIza[0-9A-Za-z_-]{35}\b" + r"|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" + r"|(?<=://)[^\s/:@]+:[^\s/@]+(?=@)" +) + +REDACTED_MARKER = "[redacted]" + + +def redact_secrets(text: str | None) -> str: + """Replace credential-shaped substrings in *text* with ``[redacted]``. + + Applied to free text copied out of source files (docstrings and ``# WHY:`` + style comments) before it becomes a node label, so a token pasted into a + comment does not land in graph.json / graph.html / GRAPH_REPORT.md. + """ + if not text: + return "" if text is None else text + return _SECRET_SHAPE_RE.sub(REDACTED_MARKER, str(text)) + + def sanitize_label(text: str | None) -> str: """Strip control characters and cap length. diff --git a/tests/test_rationale.py b/tests/test_rationale.py index 4f617177b..a0e9dbdad 100644 --- a/tests/test_rationale.py +++ b/tests/test_rationale.py @@ -600,3 +600,68 @@ def test_js_short_rationale_comment_unchanged(tmp_path): assert [n["label"] for n in rationale] == [ "NOTE: must run before compile() or the linker will fail" ] + + +# ── Secret redaction in rationale labels ────────────────────────────────────── + +_GH_TOKEN = "ghp_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8" +_AWS_KEY = "AKIA" + "IOSFODNN7EXAMPLE" + + +def _labels(result): + return [n["label"] for n in result["nodes"] if n.get("file_type") == "rationale"] + + +def test_comment_secret_redacted_from_rationale_label(tmp_path): + path = _write_py(tmp_path, f''' + def deploy(): + # WHY: the prod token is {_GH_TOKEN} so CI can push + pass + ''') + labels = _labels(extract_python(path)) + assert any("WHY" in label for label in labels) + assert not any(_GH_TOKEN in label or "ghp_a1B2" in label for label in labels) + assert any("[redacted]" in label for label in labels) + + +def test_docstring_secret_redacted_from_rationale_label(tmp_path): + path = _write_py(tmp_path, f''' + def connect(): + """Uses {_AWS_KEY} because the legacy bucket needs static credentials.""" + pass + ''') + labels = _labels(extract_python(path)) + assert labels and not any(_AWS_KEY in label for label in labels) + + +def test_secret_not_left_as_prefix_when_label_truncates(tmp_path): + long_prefix = "word " * 14 # pushes the token across the 80-char cut + path = _write_py(tmp_path, f''' + def f(): + # NOTE: {long_prefix}{_GH_TOKEN} + pass + ''') + assert not any("ghp_" in label for label in _labels(extract_python(path))) + + +def test_secret_absent_from_persisted_graph_json(tmp_path): + import json + path = _write_py(tmp_path, f''' + def deploy(): + # WHY: prod token {_GH_TOKEN} + pass + ''') + G = build_from_json(extract_python(path)) + from networkx.readwrite import json_graph + assert "ghp_a1B2" not in json.dumps(json_graph.node_link_data(G, edges="links")) + + +def test_ordinary_rationale_untouched(tmp_path): + path = _write_py(tmp_path, ''' + def process(): + # WHY: chunked processing because the dataset exceeds RAM + pass + ''') + labels = _labels(extract_python(path)) + assert any("chunked processing because the dataset exceeds RAM" in label for label in labels) + assert not any("[redacted]" in label for label in labels) diff --git a/tests/test_security.py b/tests/test_security.py index 74669f938..eb87b38bc 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -461,3 +461,32 @@ def test_sanitize_metadata_bool_not_coerced_to_int(): assert out["flag_t"] is True assert out["flag_f"] is False assert out["num"] == 1 + + +# ── redact_secrets ──────────────────────────────────────────────────────────── + +def test_redact_secrets_shapes(): + from graphify.security import redact_secrets + cases = [ + "ghp_" + "a" * 36, + "AKIA" + "IOSFODNN7EXAMPLE", + "sk_live_" + "x" * 24, + "xoxb-1234567890-abcdefghij", + "-----BEGIN OPENSSH PRIVATE KEY-----", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghijk", + ] + for secret in cases: + out = redact_secrets(f"see {secret} here") + assert secret not in out and "[redacted]" in out, secret + + +def test_redact_secrets_url_password_keeps_host(): + from graphify.security import redact_secrets + assert redact_secrets("postgres://svc:hunter2@db.internal/app") == "postgres://[redacted]@db.internal/app" + + +def test_redact_secrets_leaves_plain_text_and_none(): + from graphify.security import redact_secrets + assert redact_secrets("use chunked processing; see https://example.com/a/b") == "use chunked processing; see https://example.com/a/b" + assert redact_secrets(None) == "" + assert redact_secrets("") == ""