fix(extract): close residual absolute-path/username leaks (#1899)

Completes #1789. Two residual leaks into committed graph.json:

(a) Out-of-root reference targets (a .csproj ProjectReference, .sln
    project, or bash `source` pointing outside the scan root) kept an
    absolute source_file and an absolute-derived id, because the
    relativization post-passes only handled paths under root and silently
    left out-of-root ones absolute. They now get a portable walk-up
    relative source_file and an `ext_`-namespaced id (basename fallback
    for far-outside or cross-drive targets), with edge endpoints remapped.

(b) A symbol whose name normalizes to nothing (minified `$`, a JSONC
    `"//"` key) collapsed _make_id(stem, name) to the bare absolute file
    stem, leaking the path and colliding with the file node. Guard at the
    three mint sites (json_config keys, engine function names) to skip
    these no-signal symbols.

Adds regression tests: an out-of-root ProjectReference stays portable
(no scan-path in any id/source_file/edge), and a minified `$` is dropped
while the real function survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-14 23:42:06 +01:00
co-authored by Claude Opus 4.8
parent e1b8a17dc8
commit b3dc15b839
6 changed files with 107 additions and 1 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.16 (unreleased)
- Fix: close two residual paths where an absolute scan path (including the OS username) still leaked into a committed `graph.json`, completing #1789 (#1899). (a) A reference target outside the scan root (an out-of-root `.csproj` ProjectReference, `.sln` project, or bash `source`) kept its absolute `source_file` and an absolute-derived id, because the relativization post-passes silently skipped anything `relative_to(root)` could not handle; such targets now get a portable walk-up relative path and an `ext_`-namespaced id (bare basename when the target is far outside the corpus or on another drive). (b) A symbol whose name normalizes to nothing (a minified `$` function, a JSONC `"//"` comment key) collapsed `_make_id(stem, name)` down to the bare absolute file stem; those no-signal symbols are now skipped at mint time.
- Fix: uppercase TypeScript extensions (`.TS`/`.TSX`/`.MTS`/`.CTS`) are now parsed with the TypeScript grammar instead of falling through to the JavaScript grammar, which silently dropped interfaces and type aliases (#1881, thanks @xkam7ar). Detection and dispatch already lowercased, but the grammar selection inside `extract_js` compared the suffix case-sensitively.
- Fix: Kotlin builtin/stdlib types (`String`, `Int`, `List`, ...) are no longer emitted as `references` edges, matching the existing Java/Python/Go builtin filtering (#1876, thanks @kebwlmbhee). They created false coupling and split clusters on real projects. User types that legitimately share a name (`Result`, framework types) are deliberately not filtered, consistent with the other languages.
+41 -1
View File
@@ -4891,7 +4891,31 @@ def extract(
# a new language plugs in without editing this body (#1356 Swift, #1446 Python).
run_language_resolvers(paths, per_file, all_nodes, all_edges)
# Relativize source_file fields so paths are portable across machines (#555)
# Relativize source_file fields so paths are portable across machines (#555).
# A target OUTSIDE the scan root (an out-of-root ProjectReference/.sln/bash
# `source`) can't be made relative to root; leaving it absolute leaked the
# scan path including the OS username into a committed graph.json (#1899).
# Fall back to a walk-up relative form, or the bare basename when that would
# still embed foreign path segments (a far-away or cross-drive target). When
# the node's id was itself minted from the absolute path, remap it to a
# portable id and rewrite the edge endpoints that reference it.
def _portable_out_of_root_sf(p: Path) -> str:
try:
rel = os.path.relpath(str(p), str(root)).replace("\\", "/")
except ValueError:
return p.name # different Windows drive: no relative path exists
updepth = 0
for seg in rel.split("/"):
if seg == "..":
updepth += 1
else:
break
# More than a couple of walk-ups means the target lives well outside the
# corpus; its ancestor dirs would embed foreign (possibly user-named)
# segments, so collapse to the basename.
return p.name if updepth > 3 else rel
ext_id_remap: dict[str, str] = {}
for item in all_nodes + all_edges:
sf = item.get("source_file")
if not sf:
@@ -4901,8 +4925,24 @@ def extract(
continue
try:
item["source_file"] = sf_path.relative_to(root).as_posix()
continue
except ValueError:
pass
portable = _portable_out_of_root_sf(sf_path)
# A node whose id was minted from this absolute path also leaks it.
if "id" in item and item.get("id") == _make_id(str(sf_path)):
ext_id_remap[item["id"]] = _make_id("ext", portable)
item["source_file"] = portable
if ext_id_remap:
for n in all_nodes:
if n.get("id") in ext_id_remap:
n["id"] = ext_id_remap[n["id"]]
for e in all_edges:
if e.get("source") in ext_id_remap:
e["source"] = ext_id_remap[e["source"]]
if e.get("target") in ext_id_remap:
e["target"] = ext_id_remap[e["target"]]
# origin_file is an internal disambiguation hint (#1462): the colliding-id pass
# above reads it to keep same-named cross-file stubs distinct, after which nothing
+11
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import importlib
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text
from graphify.ids import normalize_id
from graphify.extractors.models import LanguageConfig
from graphify.extractors.resolution import _resolve_js_import_target
from graphify.security import sanitize_metadata
@@ -1760,6 +1761,11 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
if name_node:
func_name = _read_text(name_node, source)
line = child.start_point[0] + 1
# A name that normalizes to nothing (e.g. minified `$`)
# would collapse the id to the absolute file-stem and
# leak the scan path (#1899); skip it (no graph signal).
if not normalize_id(func_name):
continue
func_nid = _make_id(stem, func_name)
add_node_fn(func_nid, f"{func_name}()", line)
add_edge_fn(file_nid, func_nid, "contains", line)
@@ -3124,6 +3130,11 @@ def _extract_generic(
if not func_name:
return
# A name that normalizes to nothing collapses `_make_id(prefix, name)`
# onto the (absolute-path-derived) prefix, leaking the scan path and
# colliding with the file/class node (#1899). No graph signal; skip.
if not normalize_id(func_name):
return
line = node.start_point[0] + 1
if parent_class_nid:
+7
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id, _read_text
from graphify.ids import normalize_id
_CONFIG_JSON_NAMES = frozenset({
@@ -140,6 +141,12 @@ def extract_json(path: Path) -> dict:
key = _key_text(child)
if not key:
continue
# A key that normalizes to nothing (a JSONC `"//"` comment key, say)
# would collapse `_make_id(stem, key)` down to the bare file-stem id,
# which is absolute-path-derived and leaks the scan path (#1899). Such
# keys carry no graph signal, so drop them.
if not normalize_id(key):
continue
key_nid = _make_id(stem, *(([parent_key] if parent_key else []) + [key]))
if not key_nid:
continue
+29
View File
@@ -126,6 +126,35 @@ def test_csproj_project_references():
assert len(imports) == 6 # 4 packages + 2 project refs
def test_csproj_out_of_root_reference_id_is_portable(tmp_path):
"""#1899: a ProjectReference to a project OUTSIDE the scan root must not leak
the absolute scan path (including the OS username) into the node id or
source_file. The out-of-root target gets a portable, `ext_`-namespaced id and
a walk-up relative source_file rather than the absolute-derived form."""
web = tmp_path / "WebApi"; web.mkdir()
core = tmp_path / "Core"; core.mkdir()
(core / "Core.csproj").write_text(
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup>'
'<TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>'
)
(web / "WebApi.csproj").write_text(
'<Project Sdk="Microsoft.NET.Sdk"><ItemGroup>'
'<ProjectReference Include="..\\Core\\Core.csproj" /></ItemGroup></Project>'
)
result = extract([web / "WebApi.csproj"], cache_root=web)
marker = str(tmp_path)
for n in result["nodes"]:
assert marker not in n["id"], f"absolute path leaked into id: {n}"
assert marker not in (n.get("source_file") or ""), f"leaked into source_file: {n}"
for e in result["edges"]:
for f in ("source", "target", "source_file"):
assert marker not in str(e.get(f, "")), f"leaked into edge {f}: {e}"
core_ref = [n for n in result["nodes"] if "core" in n["id"].lower()]
assert core_ref, "out-of-root Core reference node missing"
assert core_ref[0]["id"].startswith("ext_")
assert core_ref[0]["source_file"] == "../Core/Core.csproj"
def test_csproj_target_framework():
r = extract_csproj(FIXTURES / "sample.csproj")
assert "net8.0" in _labels(r)
+17
View File
@@ -970,6 +970,23 @@ def test_python_qualified_class_method_call_resolves_extracted(tmp_path):
assert call_edges[0]["confidence"] == "EXTRACTED"
def test_degenerate_symbol_name_does_not_leak_absolute_id(tmp_path):
"""#1899 variant B: a symbol whose name normalizes to nothing (a minified `$`
function, a JSONC `"//"` key) must not be minted — `_make_id(stem, "")`
collapses to the bare, absolute-path-derived file stem, leaking the scan path
and colliding with the file node. Such nodes carry no graph signal."""
(tmp_path / "vendor.js").write_text(
"function $(){return 1}\nfunction real(){return 2}\n", encoding="utf-8"
)
result = extract([tmp_path / "vendor.js"], cache_root=tmp_path)
marker = str(tmp_path)
for n in result["nodes"]:
assert marker not in n["id"], f"absolute path leaked into id: {n}"
labels = {n.get("label") for n in result["nodes"]}
assert "real()" in labels, "the real function must still be extracted"
assert "$()" not in labels, "the degenerate `$` symbol must be dropped (#1899)"
def test_python_module_qualified_call_resolves_extracted(tmp_path):
"""`module.func()` where `module` is imported resolves to the callable that
module contains, with an EXTRACTED `calls` edge (#1883). A lowercase module