fix(detect): stop the sensitive-file filter from dropping topic docs and real source (#2106)

The heuristic over-matched and silently dropped legitimate files:
- prose `.md`/`.rst` whose topic slug ends in a keyword (privacy-tokens.md,
  token-economics.md) — only code was exempt, not prose;
- the unbounded Stage-2 `service.account` substring (regex `.` wildcard) matched
  real source (google/oauth2/service_account.py) and prose slugs.
It also MISSED real secrets (.npmrc, .pypirc, secring, .git-credentials, and
case variants on case-insensitive filesystems), which were being indexed.

Fix: move service_account/aws_credentials to the boundary-checked keyword path
(so real source is spared, downloaded key files still drop), add a prose-note
carve-out (multi-word slugs indexed, bare `secrets.md`/`token.md` still dropped),
tighten id_rsa with a left boundary, add the missed secret dotfiles + secring,
lowercase the dir/segment comparisons, and count multi-dot slugs as multi-word.
Net effect is stricter on real secrets and stops the false-positive data loss.

Traceability: `graphify extract` now names the files skipped as sensitive (not
just a count), so a wrongly-flagged file is visible.
This commit is contained in:
safishamsi
2026-07-22 18:40:11 +01:00
parent 82c46e5358
commit 36b5e770eb
4 changed files with 124 additions and 13 deletions
+13
View File
@@ -2817,6 +2817,19 @@ def dispatch_command(cmd: str) -> None:
f"[graphify extract] {len(_unclassified)} file(s) not classified "
f"(no supported extension or shebang), skipped: {_names}{_more}"
)
# Name the files dropped by the sensitive-file filter so a wrongly-flagged
# source/doc is visible, not just a count (#2106). Operational skips
# (symlink/office/Workspace) carry a " [reason]" suffix; exclude those here
# so this line reports only the security-heuristic drops.
_sensitive = detection.get("skipped_sensitive", []) if isinstance(detection, dict) else []
_sec = [s for s in _sensitive if " [" not in s]
if _sec:
_snames = ", ".join(sorted({Path(p).name for p in _sec})[:6])
_smore = f" (+{len(_sec) - 6} more)" if len(_sec) > 6 else ""
print(
f"[graphify extract] {len(_sec)} file(s) skipped as potentially sensitive "
f"(rename or move if wrongly flagged): {_snames}{_smore}"
)
stages.mark("detect")
# Resolve the LLM backend only now that we know whether the corpus
+48 -10
View File
@@ -116,9 +116,17 @@ _AMBIGUOUS_SENSITIVE_DIRS = frozenset({
_SENSITIVE_PATTERNS = [
re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE),
re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE),
re.compile(r'(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$'),
re.compile(r'(\.netrc|\.pgpass|\.htpasswd)$', re.IGNORECASE),
re.compile(r'(aws_credentials|gcloud_credentials|service.account)', re.IGNORECASE),
# SSH/GPG private keys. Left boundary + IGNORECASE so `grid_rsa` (alpha before
# `id_rsa`) and `ID_RSA` are handled correctly, not matched as a substring.
re.compile(r'(^|[^A-Za-z0-9])(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$', re.IGNORECASE),
re.compile(r'^secring(\.(gpg|pgp))?$', re.IGNORECASE), # GPG private keyring
# Auth/credential dotfiles that routinely hold tokens (#2106: .npmrc/.pypirc/
# .git-credentials/.boto were silently indexed before).
re.compile(r'(\.netrc|\.pgpass|\.htpasswd|\.npmrc|\.pypirc|\.git-credentials|\.boto)$', re.IGNORECASE),
# NOTE: aws_credentials/gcloud_credentials/service_account moved to the
# boundary-checked Stage 3 keyword path (#2106). The old unbounded
# `service.account` substring (regex `.` wildcard) matched real source like
# google/oauth2/service_account.py and prose like aws_credentials_rotation.md.
]
# Generic keyword patterns - these only count when the keyword is LOAD-BEARING
@@ -134,8 +142,21 @@ _SENSITIVE_PATTERNS = [
_GENERIC_KEYWORD_PATTERNS = [
re.compile(r'(?<![a-zA-Z0-9])(credential|secret|passwd|password|private_key)s?(?![a-zA-Z])', re.IGNORECASE),
re.compile(r'(?<![a-zA-Z0-9])tokens?(?![a-zA-Z])', re.IGNORECASE),
# service_account / service-account / serviceaccount (GCP key files). In the
# keyword path so `service_account.py` (real source) is spared while
# `service-account.json` (a downloaded key) and bare names are still caught
# (#2106; was an unbounded Stage 2 substring). aws_credentials/gcloud_credentials
# are already covered by the `credential` keyword above.
re.compile(r'(?<![a-zA-Z0-9])service[._-]?account(?![a-zA-Z])', re.IGNORECASE),
]
# Prose/note formats: a heavily-linked wiki article whose topic slug ends in a
# keyword (privacy-tokens.md, token-economics.md) is a document ABOUT the topic,
# not a credential store, so it must not be silently dropped (#2106). A BARE
# keyword name (secrets.md, token.md, passwords.md) still reads as a dump and
# stays excluded — see _is_prose_note.
_PROSE_EXTS = frozenset({".md", ".markdown", ".rst", ".org", ".adoc", ".tex"})
# Data/serialization extensions that commonly ARE secret stores when their name
# hits a generic keyword (credentials.json, secrets.yaml, token.toml) or they sit
# in an ambiguous sensitive dir (secrets/db.json). These stay subject to the
@@ -154,7 +175,18 @@ _SECRET_PRONE_DATA_EXTS = frozenset({
# Word separators for the load-bearing check (underscore intentionally included;
# multi-word keywords like private_key are handled by the end-of-stem check,
# which runs before word counting).
_WORD_SPLIT = re.compile(r'[-_\s]+')
_WORD_SPLIT = re.compile(r'[-_\s.]+') # '.' included so `token.economics.notes` counts as 3 words (#2106)
def _is_prose_note(path: Path) -> bool:
"""A prose/note file (.md/.rst/...) whose stem is a multi-word topic slug is
exempt from the generic-keyword drop (#2106). A stem that IS exactly a bare
keyword (secrets / token / passwords) is NOT exempt that still reads as a
credential dump."""
if path.suffix.lower() not in _PROSE_EXTS:
return False
stem = Path(path.name).stem.lstrip('.') or Path(path.name).stem
return not any(p.fullmatch(stem) for p in _GENERIC_KEYWORD_PATTERNS)
def _generic_keyword_hit(name: str) -> bool:
@@ -167,9 +199,11 @@ def _generic_keyword_hit(name: str) -> bool:
("token-economics-of-recall.md", "password-policy-discussion.md") and must
not cause the file to be silently dropped from the graph (#436, #718).
"""
# Stem = name up to the first dot, ignoring leading dots so dotfiles like
# ".token" keep their keyword ("" stems would never match).
stem = name.lstrip('.').split('.')[0]
# Stem = name minus only the FINAL extension (not up to the first dot), so a
# multi-dot topic slug like `token.economics.notes.md` keeps all its words and
# doesn't collapse to a bare `token` (#2106). Leading dots stripped so
# dotfiles like `.token` keep their keyword.
stem = Path(name).stem.lstrip('.') or Path(name).stem
for pat in _GENERIC_KEYWORD_PATTERNS:
hit = False
for m in pat.finditer(stem):
@@ -218,9 +252,11 @@ def _is_sensitive(path: Path) -> bool:
# (secrets/, credentials/) spare genuine source (#1943), which still falls
# through so Stages 2-3 screen its filename like anywhere else.
parents = path.parts[:-1]
if any(part in _CREDENTIAL_STORE_DIRS for part in parents):
# Lowercase the segment comparison so `Secrets/`/`SECRETS/` (real on
# case-insensitive macOS/Windows filesystems) are still caught (#2106).
if any(part.lower() in _CREDENTIAL_STORE_DIRS for part in parents):
return True
if any(part in _AMBIGUOUS_SENSITIVE_DIRS for part in parents) and not _is_graphable_source(path):
if any(part.lower() in _AMBIGUOUS_SENSITIVE_DIRS for part in parents) and not _is_graphable_source(path):
return True
# Stage 2: filename pattern match
name = path.name
@@ -235,7 +271,9 @@ def _is_sensitive(path: Path) -> bool:
# secret stores this stage must catch. The specific Stage 2 patterns (.env, .pem,
# id_rsa, ...) still apply to everything regardless of extension.
if _generic_keyword_hit(name):
return not _is_graphable_source(path)
# Genuine source AND multi-word prose notes are exempt; a bare-keyword
# name (secrets.md, token.txt) still drops (#1666, #2106).
return not (_is_graphable_source(path) or _is_prose_note(path))
return False