Fix 8 bugs: SSRF in tweet fetch, Cypher injection, XSS in community labels, graph_diff edge ordering, cache key collision, hook uninstall correctness, watch mode set, rebuild word count

This commit is contained in:
Safi
2026-04-06 22:23:55 +01:00
parent 4e844ea907
commit c529e2217f
6 changed files with 49 additions and 32 deletions
+1 -1
View File
@@ -472,7 +472,7 @@ def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict:
]
def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple:
return (u, v, data.get("relation", ""))
return (min(u, v), max(u, v), data.get("relation", ""))
old_edge_keys = {
edge_key(G_old, u, v, d)
+7 -2
View File
@@ -8,8 +8,13 @@ from pathlib import Path
def file_hash(path: Path) -> str:
"""SHA256 of file contents, hex digest."""
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
"""SHA256 of file contents + resolved path. Prevents cache collisions on identical content."""
p = Path(path)
h = hashlib.sha256()
h.update(p.read_bytes())
h.update(b"\x00")
h.update(str(p.resolve()).encode())
return h.hexdigest()
def cache_dir(root: Path = Path(".")) -> Path:
+15 -7
View File
@@ -271,18 +271,26 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) ->
json.dump(data, f, indent=2)
def _cypher_escape(s: str) -> str:
"""Escape a string for safe embedding in a Cypher single-quoted literal."""
return s.replace("\\", "\\\\").replace("'", "\\'")
def to_cypher(G: nx.Graph, output_path: str) -> None:
lines = ["// Neo4j Cypher import - generated by /graphify", ""]
for node_id, data in G.nodes(data=True):
label = data.get("label", node_id).replace("'", "\\'")
ftype = data.get("file_type", "unknown").capitalize()
lines.append(f"MERGE (n:{ftype} {{id: '{node_id}', label: '{label}'}});")
label = _cypher_escape(data.get("label", node_id))
node_id_esc = _cypher_escape(node_id)
ftype = re.sub(r"[^A-Za-z0-9_]", "", data.get("file_type", "unknown").capitalize()) or "Entity"
lines.append(f"MERGE (n:{ftype} {{id: '{node_id_esc}', label: '{label}'}});")
lines.append("")
for u, v, data in G.edges(data=True):
rel = data.get("relation", "RELATES_TO").upper().replace(" ", "_").replace("-", "_")
conf = data.get("confidence", "EXTRACTED")
rel = re.sub(r"[^A-Za-z0-9_]", "_", data.get("relation", "RELATES_TO").upper())
conf = _cypher_escape(data.get("confidence", "EXTRACTED"))
u_esc = _cypher_escape(u)
v_esc = _cypher_escape(v)
lines.append(
f"MATCH (a {{id: '{u}'}}), (b {{id: '{v}'}}) "
f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) "
f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);"
)
with open(output_path, "w") as f:
@@ -329,7 +337,7 @@ def to_html(
"font": {"size": font_size, "color": "#ffffff"},
"title": f"{label}",
"community": cid,
"community_name": (community_labels or {}).get(cid, f"Community {cid}"),
"community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
"source_file": sanitize_label(data.get("source_file", "")),
"file_type": data.get("file_type", ""),
"degree": deg,
+22 -15
View File
@@ -1,13 +1,15 @@
# git hook integration - install/uninstall graphify post-commit and post-checkout hooks
from __future__ import annotations
import re
from pathlib import Path
_HOOK_MARKER = "# graphify-hook"
_CHECKOUT_MARKER = "# graphify-checkout-hook"
_HOOK_MARKER = "# graphify-hook-start"
_HOOK_MARKER_END = "# graphify-hook-end"
_CHECKOUT_MARKER = "# graphify-checkout-hook-start"
_CHECKOUT_MARKER_END = "# graphify-checkout-hook-end"
_HOOK_SCRIPT = """\
#!/bin/bash
# graphify-hook
# graphify-hook-start
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
# Installed by: graphify hook install
@@ -42,12 +44,12 @@ except Exception as exc:
print(f'[graphify hook] Rebuild failed: {exc}')
sys.exit(1)
"
# graphify-hook-end
"""
_CHECKOUT_SCRIPT = """\
#!/bin/bash
# graphify-checkout-hook
# graphify-checkout-hook-start
# Auto-rebuilds the knowledge graph (code only) when switching branches.
# Installed by: graphify hook install
@@ -76,6 +78,7 @@ except Exception as exc:
print(f'[graphify] Rebuild failed: {exc}')
sys.exit(1)
"
# graphify-checkout-hook-end
"""
@@ -97,25 +100,29 @@ def _install_hook(hooks_dir: Path, name: str, script: str, marker: str) -> str:
return f"already installed at {hook_path}"
hook_path.write_text(content.rstrip() + "\n\n" + script)
return f"appended to existing {name} hook at {hook_path}"
hook_path.write_text(script)
hook_path.write_text("#!/bin/bash\n" + script)
hook_path.chmod(0o755)
return f"installed at {hook_path}"
def _uninstall_hook(hooks_dir: Path, name: str, marker: str) -> str:
"""Remove graphify section from a git hook."""
def _uninstall_hook(hooks_dir: Path, name: str, marker: str, marker_end: str) -> str:
"""Remove graphify section from a git hook using start/end markers."""
hook_path = hooks_dir / name
if not hook_path.exists():
return f"no {name} hook found - nothing to remove."
content = hook_path.read_text()
if marker not in content:
return f"graphify hook not found in {name} - nothing to remove."
before = content.split(marker)[0].rstrip()
non_empty = [l for l in before.splitlines() if l.strip() and not l.startswith("#!")]
if not non_empty:
new_content = re.sub(
rf"{re.escape(marker)}.*?{re.escape(marker_end)}\n?",
"",
content,
flags=re.DOTALL,
).strip()
if not new_content or new_content == "#!/bin/bash":
hook_path.unlink()
return f"removed {name} hook at {hook_path}"
hook_path.write_text(before + "\n")
hook_path.write_text(new_content + "\n")
return f"graphify removed from {name} at {hook_path} (other hook content preserved)"
@@ -141,8 +148,8 @@ def uninstall(path: Path = Path(".")) -> str:
raise RuntimeError(f"No git repository found at or above {path.resolve()}")
hooks_dir = root / ".git" / "hooks"
commit_msg = _uninstall_hook(hooks_dir, "post-commit", _HOOK_MARKER)
checkout_msg = _uninstall_hook(hooks_dir, "post-checkout", _CHECKOUT_MARKER)
commit_msg = _uninstall_hook(hooks_dir, "post-commit", _HOOK_MARKER, _HOOK_MARKER_END)
checkout_msg = _uninstall_hook(hooks_dir, "post-checkout", _CHECKOUT_MARKER, _CHECKOUT_MARKER_END)
return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}"
+1 -3
View File
@@ -67,9 +67,7 @@ def _fetch_tweet(url: str, author: str | None, contributor: str | None) -> tuple
oembed_url = url.replace("x.com", "twitter.com")
oembed_api = f"https://publish.twitter.com/oembed?url={urllib.parse.quote(oembed_url)}&omit_script=true"
try:
req = urllib.request.Request(oembed_api, headers={"User-Agent": "graphify/1.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
data = json.loads(safe_fetch_text(oembed_api))
tweet_text = re.sub(r"<[^>]+>", "", data.get("html", "")).strip()
tweet_author = data.get("author_name", "unknown")
except Exception:
+3 -4
View File
@@ -50,7 +50,7 @@ def _rebuild_code(watch_path: Path) -> bool:
detection = {
"files": {"code": [str(f) for f in code_files], "document": [], "paper": [], "image": []},
"total_files": len(code_files),
"total_words": sum(len(f.read_text(errors="ignore").split()) for f in code_files),
"total_words": 0, # not needed during watch rebuild
}
G = build_from_json(result)
@@ -118,7 +118,7 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None:
last_trigger: float = 0.0
pending: bool = False
changed: list[Path] = []
changed: set[Path] = set()
class Handler(FileSystemEventHandler):
def on_any_event(self, event):
@@ -134,8 +134,7 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None:
return
last_trigger = time.monotonic()
pending = True
if path not in changed:
changed.append(path)
changed.add(path)
handler = Handler()
observer = Observer()