mirror of
https://github.com/safishamsi/graphify.git
synced 2026-11-04 21:13:46 +00:00
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:
1 parent
ecf9a81c39
commit
44bd623a2e
4 files changed
+129
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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("") == ""
|
||||
Reference in new issue
Block a user