fix(extract): redact credential-shaped text from rationale labels

Docstrings and `# WHY:`/`# NOTE:` comments become rationale node labels,
which are persisted verbatim to graph.json, graph.html, GRAPH_REPORT.md and
the AST cache. A token pasted into a comment therefore ended up in the
artifacts the README tells users to commit and share.

Add security.redact_secrets() (shape-based: vendor-prefixed tokens, PEM
private-key headers, JWTs, URL passwords) and apply it in
_shorten_rationale_label(), before truncation so a cut can't leave a partial
token. That helper is shared by the Python and JS/TS rationale extractors.

Related to #2781 (document text sent to LLM backends unredacted); this
covers only the comment/docstring -> graph path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
0xkaushik-aiandClaude Sonnet 5.5 authored and safishamsi committed 2026-10-20 10:53:04 +01:00
1 parent ecf9a81c39
commit 44bd623a2e
4 files changed
+129

No files matched your search

+4
View File
@@ -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())
+31
View File
@@ -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.
+65
View File
@@ -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)
+29
View File
@@ -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("") == ""