graphitect 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphify/__init__.py +30 -0
- graphify/__main__.py +757 -0
- graphify/_minhash.py +107 -0
- graphify/affected.py +318 -0
- graphify/always_on/agents-md.md +12 -0
- graphify/always_on/antigravity-rules.md +14 -0
- graphify/always_on/claude-md.md +9 -0
- graphify/always_on/gemini-md.md +9 -0
- graphify/always_on/kiro-steering.md +5 -0
- graphify/always_on/vscode-instructions.md +17 -0
- graphify/analyze.py +769 -0
- graphify/benchmark.py +152 -0
- graphify/build.py +2300 -0
- graphify/cache.py +1746 -0
- graphify/callflow_html.py +2051 -0
- graphify/cargo_introspect.py +109 -0
- graphify/cli.py +4745 -0
- graphify/cluster.py +409 -0
- graphify/command-kilo.md +15 -0
- graphify/cross_repo_calls.py +216 -0
- graphify/cross_repo_types.py +75 -0
- graphify/csharp_dispatch.py +154 -0
- graphify/dedup.py +1213 -0
- graphify/detect.py +2566 -0
- graphify/diagnostics.py +406 -0
- graphify/export.py +1349 -0
- graphify/exporters/__init__.py +1 -0
- graphify/exporters/base.py +14 -0
- graphify/exporters/graphdb.py +173 -0
- graphify/exporters/html.py +637 -0
- graphify/extract.py +7856 -0
- graphify/extractors/MIGRATION.md +107 -0
- graphify/extractors/__init__.py +66 -0
- graphify/extractors/apex.py +215 -0
- graphify/extractors/base.py +85 -0
- graphify/extractors/bash.py +579 -0
- graphify/extractors/blade.py +53 -0
- graphify/extractors/commonlisp.py +540 -0
- graphify/extractors/csharp.py +448 -0
- graphify/extractors/dart.py +564 -0
- graphify/extractors/dm.py +494 -0
- graphify/extractors/elixir.py +241 -0
- graphify/extractors/engine.py +6509 -0
- graphify/extractors/fortran.py +311 -0
- graphify/extractors/go.py +527 -0
- graphify/extractors/json_config.py +240 -0
- graphify/extractors/julia.py +289 -0
- graphify/extractors/markdown.py +408 -0
- graphify/extractors/models.py +131 -0
- graphify/extractors/objc.py +566 -0
- graphify/extractors/ocaml.py +289 -0
- graphify/extractors/pascal.py +688 -0
- graphify/extractors/pascal_forms.py +196 -0
- graphify/extractors/powershell.py +522 -0
- graphify/extractors/razor.py +192 -0
- graphify/extractors/resolution.py +3584 -0
- graphify/extractors/robot.py +296 -0
- graphify/extractors/rust.py +470 -0
- graphify/extractors/sln.py +92 -0
- graphify/extractors/sql.py +720 -0
- graphify/extractors/terraform.py +181 -0
- graphify/extractors/verilog.py +329 -0
- graphify/extractors/zig.py +181 -0
- graphify/file_slice.py +246 -0
- graphify/global_graph.py +194 -0
- graphify/google_workspace.py +237 -0
- graphify/hooks.py +933 -0
- graphify/ids.py +93 -0
- graphify/ingest.py +358 -0
- graphify/install.py +2366 -0
- graphify/llm.py +3544 -0
- graphify/manifest.py +4 -0
- graphify/manifest_ingest.py +311 -0
- graphify/mcp_ingest.py +386 -0
- graphify/multigraph_compat.py +212 -0
- graphify/pascal_resolution.py +129 -0
- graphify/paths.py +436 -0
- graphify/pg_introspect.py +165 -0
- graphify/prs.py +770 -0
- graphify/querylog.py +80 -0
- graphify/reflect.py +882 -0
- graphify/report.py +346 -0
- graphify/resolver_registry.py +85 -0
- graphify/ruby_resolution.py +242 -0
- graphify/scip_ingest.py +363 -0
- graphify/security.py +460 -0
- graphify/semantic_cleanup.py +336 -0
- graphify/serve.py +2608 -0
- graphify/skill-agents.md +710 -0
- graphify/skill-aider.md +1283 -0
- graphify/skill-amp.md +710 -0
- graphify/skill-claw.md +713 -0
- graphify/skill-codex.md +710 -0
- graphify/skill-copilot.md +713 -0
- graphify/skill-devin.md +1410 -0
- graphify/skill-droid.md +710 -0
- graphify/skill-kilo.md +722 -0
- graphify/skill-kiro.md +713 -0
- graphify/skill-opencode.md +705 -0
- graphify/skill-pi.md +713 -0
- graphify/skill-trae.md +711 -0
- graphify/skill-vscode.md +709 -0
- graphify/skill-windows.md +755 -0
- graphify/skill.md +713 -0
- graphify/skills/agents/references/add-watch.md +56 -0
- graphify/skills/agents/references/exports.md +87 -0
- graphify/skills/agents/references/extraction-spec.md +70 -0
- graphify/skills/agents/references/github-and-merge.md +46 -0
- graphify/skills/agents/references/hooks.md +33 -0
- graphify/skills/agents/references/query.md +311 -0
- graphify/skills/agents/references/transcribe.md +52 -0
- graphify/skills/agents/references/update.md +210 -0
- graphify/skills/amp/references/add-watch.md +56 -0
- graphify/skills/amp/references/exports.md +87 -0
- graphify/skills/amp/references/extraction-spec.md +70 -0
- graphify/skills/amp/references/github-and-merge.md +46 -0
- graphify/skills/amp/references/hooks.md +33 -0
- graphify/skills/amp/references/query.md +311 -0
- graphify/skills/amp/references/transcribe.md +52 -0
- graphify/skills/amp/references/update.md +210 -0
- graphify/skills/claude/references/add-watch.md +56 -0
- graphify/skills/claude/references/exports.md +87 -0
- graphify/skills/claude/references/extraction-spec.md +70 -0
- graphify/skills/claude/references/github-and-merge.md +46 -0
- graphify/skills/claude/references/hooks.md +33 -0
- graphify/skills/claude/references/query.md +311 -0
- graphify/skills/claude/references/transcribe.md +52 -0
- graphify/skills/claude/references/update.md +210 -0
- graphify/skills/claw/references/add-watch.md +56 -0
- graphify/skills/claw/references/exports.md +87 -0
- graphify/skills/claw/references/extraction-spec.md +31 -0
- graphify/skills/claw/references/github-and-merge.md +46 -0
- graphify/skills/claw/references/hooks.md +33 -0
- graphify/skills/claw/references/query.md +311 -0
- graphify/skills/claw/references/transcribe.md +52 -0
- graphify/skills/claw/references/update.md +210 -0
- graphify/skills/codex/references/add-watch.md +56 -0
- graphify/skills/codex/references/exports.md +87 -0
- graphify/skills/codex/references/extraction-spec.md +31 -0
- graphify/skills/codex/references/github-and-merge.md +46 -0
- graphify/skills/codex/references/hooks.md +33 -0
- graphify/skills/codex/references/query.md +311 -0
- graphify/skills/codex/references/transcribe.md +52 -0
- graphify/skills/codex/references/update.md +210 -0
- graphify/skills/copilot/references/add-watch.md +56 -0
- graphify/skills/copilot/references/exports.md +87 -0
- graphify/skills/copilot/references/extraction-spec.md +70 -0
- graphify/skills/copilot/references/github-and-merge.md +46 -0
- graphify/skills/copilot/references/hooks.md +33 -0
- graphify/skills/copilot/references/query.md +311 -0
- graphify/skills/copilot/references/transcribe.md +52 -0
- graphify/skills/copilot/references/update.md +210 -0
- graphify/skills/droid/references/add-watch.md +56 -0
- graphify/skills/droid/references/exports.md +87 -0
- graphify/skills/droid/references/extraction-spec.md +70 -0
- graphify/skills/droid/references/github-and-merge.md +46 -0
- graphify/skills/droid/references/hooks.md +33 -0
- graphify/skills/droid/references/query.md +311 -0
- graphify/skills/droid/references/transcribe.md +52 -0
- graphify/skills/droid/references/update.md +210 -0
- graphify/skills/kilo/references/add-watch.md +56 -0
- graphify/skills/kilo/references/exports.md +87 -0
- graphify/skills/kilo/references/extraction-spec.md +70 -0
- graphify/skills/kilo/references/github-and-merge.md +46 -0
- graphify/skills/kilo/references/hooks.md +33 -0
- graphify/skills/kilo/references/query.md +311 -0
- graphify/skills/kilo/references/transcribe.md +52 -0
- graphify/skills/kilo/references/update.md +210 -0
- graphify/skills/kiro/references/add-watch.md +56 -0
- graphify/skills/kiro/references/exports.md +87 -0
- graphify/skills/kiro/references/extraction-spec.md +31 -0
- graphify/skills/kiro/references/github-and-merge.md +46 -0
- graphify/skills/kiro/references/hooks.md +33 -0
- graphify/skills/kiro/references/query.md +311 -0
- graphify/skills/kiro/references/transcribe.md +52 -0
- graphify/skills/kiro/references/update.md +210 -0
- graphify/skills/opencode/references/add-watch.md +56 -0
- graphify/skills/opencode/references/exports.md +87 -0
- graphify/skills/opencode/references/extraction-spec.md +70 -0
- graphify/skills/opencode/references/github-and-merge.md +46 -0
- graphify/skills/opencode/references/hooks.md +33 -0
- graphify/skills/opencode/references/query.md +311 -0
- graphify/skills/opencode/references/transcribe.md +52 -0
- graphify/skills/opencode/references/update.md +210 -0
- graphify/skills/pi/references/add-watch.md +56 -0
- graphify/skills/pi/references/exports.md +87 -0
- graphify/skills/pi/references/extraction-spec.md +31 -0
- graphify/skills/pi/references/github-and-merge.md +46 -0
- graphify/skills/pi/references/hooks.md +33 -0
- graphify/skills/pi/references/query.md +311 -0
- graphify/skills/pi/references/transcribe.md +52 -0
- graphify/skills/pi/references/update.md +210 -0
- graphify/skills/trae/references/add-watch.md +56 -0
- graphify/skills/trae/references/exports.md +87 -0
- graphify/skills/trae/references/extraction-spec.md +70 -0
- graphify/skills/trae/references/github-and-merge.md +46 -0
- graphify/skills/trae/references/hooks.md +35 -0
- graphify/skills/trae/references/query.md +311 -0
- graphify/skills/trae/references/transcribe.md +52 -0
- graphify/skills/trae/references/update.md +210 -0
- graphify/skills/vscode/references/add-watch.md +56 -0
- graphify/skills/vscode/references/exports.md +87 -0
- graphify/skills/vscode/references/extraction-spec.md +70 -0
- graphify/skills/vscode/references/github-and-merge.md +46 -0
- graphify/skills/vscode/references/hooks.md +33 -0
- graphify/skills/vscode/references/query.md +311 -0
- graphify/skills/vscode/references/transcribe.md +52 -0
- graphify/skills/vscode/references/update.md +210 -0
- graphify/skills/windows/references/add-watch.md +56 -0
- graphify/skills/windows/references/exports.md +87 -0
- graphify/skills/windows/references/extraction-spec.md +70 -0
- graphify/skills/windows/references/github-and-merge.md +46 -0
- graphify/skills/windows/references/hooks.md +33 -0
- graphify/skills/windows/references/query.md +311 -0
- graphify/skills/windows/references/transcribe.md +52 -0
- graphify/skills/windows/references/update.md +210 -0
- graphify/symbol_resolution.py +556 -0
- graphify/transcribe.py +186 -0
- graphify/tree_html.py +603 -0
- graphify/validate.py +95 -0
- graphify/watch.py +2280 -0
- graphify/wiki.py +405 -0
- graphitect/__init__.py +28 -0
- graphitect/__main__.py +4 -0
- graphitect/_vendor/__init__.py +2 -0
- graphitect/_vendor/archify/LICENSE +22 -0
- graphitect/_vendor/archify/SKILL.md +137 -0
- graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
- graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
- graphitect/_vendor/archify/assets/template.html +14935 -0
- graphitect/_vendor/archify/bin/archify.mjs +2091 -0
- graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
- graphitect/_vendor/archify/bin/preview.mjs +653 -0
- graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
- graphitect/_vendor/archify/brand-marks/README.md +31 -0
- graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
- graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
- graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
- graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
- graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
- graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
- graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
- graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
- graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
- graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
- graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
- graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
- graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
- graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
- graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
- graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
- graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
- graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
- graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
- graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
- graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
- graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
- graphitect/_vendor/archify/package-lock.json +149 -0
- graphitect/_vendor/archify/package.json +39 -0
- graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
- graphitect/_vendor/archify/references/authoring-contract.md +243 -0
- graphitect/_vendor/archify/references/brand-marks.md +65 -0
- graphitect/_vendor/archify/references/delivery-contract.md +120 -0
- graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
- graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
- graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
- graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
- graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
- graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
- graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
- graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
- graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
- graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
- graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
- graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
- graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
- graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
- graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
- graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
- graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
- graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
- graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
- graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
- graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- graphitect/_vendor/archify/schemas/README.md +211 -0
- graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
- graphitect/_vendor/archify/schemas/common.schema.json +115 -0
- graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
- graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
- graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
- graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
- graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
- graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
- graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
- graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
- graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
- graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
- graphitect/_vendor/archify/skill-release.json +10 -0
- graphitect/cli.py +981 -0
- graphitect/deliver/__init__.py +5 -0
- graphitect/deliver/archify_adapter.py +1877 -0
- graphitect/deliver/archify_ir.py +160 -0
- graphitect/deliver/archify_repair.py +135 -0
- graphitect/deliver/doc_compiler.py +916 -0
- graphitect/ground/__init__.py +5 -0
- graphitect/ground/describe_source.py +27 -0
- graphitect/ground/fullread_source.py +56 -0
- graphitect/ground/graphify_source.py +107 -0
- graphitect/models.py +118 -0
- graphitect/skill/SKILL.md +80 -0
- graphitect/skill/agents/openai.yaml +4 -0
- graphitect/synthesize/__init__.py +5 -0
- graphitect/synthesize/engine.py +281 -0
- graphitect/synthesize/llm_backend.py +331 -0
- graphitect/synthesize/questions.py +139 -0
- graphitect/synthesize/rubric.py +104 -0
- graphitect-0.2.0.dist-info/METADATA +284 -0
- graphitect-0.2.0.dist-info/RECORD +336 -0
- graphitect-0.2.0.dist-info/WHEEL +5 -0
- graphitect-0.2.0.dist-info/entry_points.txt +2 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
- graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/security.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# Security helpers - URL validation, safe fetch, path guards, label sanitisation
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
import http.client
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import ipaddress
|
|
16
|
+
import socket
|
|
17
|
+
|
|
18
|
+
from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME
|
|
19
|
+
|
|
20
|
+
_ALLOWED_SCHEMES = {"http", "https"}
|
|
21
|
+
_MAX_FETCH_BYTES = 52_428_800 # 50 MB hard cap for binary downloads
|
|
22
|
+
_MAX_TEXT_BYTES = 10_485_760 # 10 MB hard cap for HTML / text
|
|
23
|
+
|
|
24
|
+
# Graph-load memory-bomb cap: reject .json files larger than this before
|
|
25
|
+
# JSON-parsing them into a dict. Without this, a multi-gigabyte (or
|
|
26
|
+
# specifically crafted) graph.json can exhaust process memory during
|
|
27
|
+
# json.loads + node_link_graph rehydration.
|
|
28
|
+
# Default fallback cap. Kept as a module-level constant so the value is
|
|
29
|
+
# discoverable and so existing callers/tests that reference it directly keep
|
|
30
|
+
# working; the effective cap is resolved at call time by
|
|
31
|
+
# ``_max_graph_file_bytes`` (which lets ``GRAPHIFY_MAX_GRAPH_BYTES`` override it).
|
|
32
|
+
_MAX_GRAPH_FILE_BYTES = 512 * 1024 * 1024 # 512 MiB
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _max_graph_file_bytes() -> int:
|
|
36
|
+
"""Return the graph.json size cap in bytes.
|
|
37
|
+
|
|
38
|
+
Honors the ``GRAPHIFY_MAX_GRAPH_BYTES`` environment variable so users with
|
|
39
|
+
large codebases can raise the limit without editing source. The value may
|
|
40
|
+
be plain bytes (``671088640``) or carry an ``MB`` / ``GB`` suffix
|
|
41
|
+
(``640MB``, ``2GB`` — case-insensitive, binary multipliers: ``MB`` is
|
|
42
|
+
1024*1024 and ``GB`` is 1024*1024*1024, i.e. MiB / GiB).
|
|
43
|
+
Falls back to ``_MAX_GRAPH_FILE_BYTES`` (512 MiB) when the env var is unset,
|
|
44
|
+
blank, or unparseable.
|
|
45
|
+
|
|
46
|
+
Read fresh on every call so the env var can be set before import and still
|
|
47
|
+
take effect.
|
|
48
|
+
"""
|
|
49
|
+
raw = os.environ.get("GRAPHIFY_MAX_GRAPH_BYTES", "").strip()
|
|
50
|
+
if not raw:
|
|
51
|
+
return _MAX_GRAPH_FILE_BYTES
|
|
52
|
+
text = raw.upper()
|
|
53
|
+
multiplier = 1
|
|
54
|
+
if text.endswith("GB"):
|
|
55
|
+
multiplier = 1024 * 1024 * 1024
|
|
56
|
+
text = text[:-2].strip()
|
|
57
|
+
elif text.endswith("MB"):
|
|
58
|
+
multiplier = 1024 * 1024
|
|
59
|
+
text = text[:-2].strip()
|
|
60
|
+
try:
|
|
61
|
+
value = int(text)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return _MAX_GRAPH_FILE_BYTES
|
|
64
|
+
if value <= 0:
|
|
65
|
+
return _MAX_GRAPH_FILE_BYTES
|
|
66
|
+
return value * multiplier
|
|
67
|
+
|
|
68
|
+
# AWS metadata, link-local, and common cloud metadata endpoints
|
|
69
|
+
_BLOCKED_HOSTS = {"metadata.google.internal", "metadata.google.com"}
|
|
70
|
+
|
|
71
|
+
# RFC 6598 Shared Address Space (CGN) -- is_private misses this on Python <3.11
|
|
72
|
+
_CGN_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
|
73
|
+
|
|
74
|
+
# RFC 6052 NAT64 Well-Known Prefix -- is_reserved=True in Python but these embed
|
|
75
|
+
# public IPv4 addresses and are legitimate public internet traffic, not SSRF vectors.
|
|
76
|
+
_NAT64_WKP = ipaddress.ip_network("64:ff9b::/96")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# URL validation
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _ip_is_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
84
|
+
"""Return True if *ip* falls in a private/reserved/internal range.
|
|
85
|
+
|
|
86
|
+
Shared by validate_url (pre-flight DNS check) and the SSRF-guarded
|
|
87
|
+
connection classes (connect-time check) so both use identical logic.
|
|
88
|
+
NAT64 well-known-prefix addresses are unwrapped to their embedded IPv4
|
|
89
|
+
before the check, since those carry legitimate public traffic.
|
|
90
|
+
"""
|
|
91
|
+
# For NAT64 addresses, check the embedded IPv4 instead of the wrapper
|
|
92
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_WKP:
|
|
93
|
+
ip = ipaddress.ip_address(int(ip) & 0xFFFFFFFF)
|
|
94
|
+
return (
|
|
95
|
+
ip.is_private
|
|
96
|
+
or ip.is_reserved
|
|
97
|
+
or ip.is_loopback
|
|
98
|
+
or ip.is_link_local
|
|
99
|
+
or ip in _CGN_NETWORK
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_url(url: str) -> str:
|
|
104
|
+
"""Raise ValueError if *url* is not http or https, or targets a private/internal IP.
|
|
105
|
+
|
|
106
|
+
Blocks file://, ftp://, data:, and any other scheme that could be used
|
|
107
|
+
for SSRF or local file access. Also blocks requests to private/reserved
|
|
108
|
+
IP ranges (127.x, 10.x, 169.254.x, etc.) and cloud metadata endpoints
|
|
109
|
+
to prevent SSRF in cloud environments.
|
|
110
|
+
"""
|
|
111
|
+
parsed = urllib.parse.urlparse(url)
|
|
112
|
+
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"Blocked URL scheme '{parsed.scheme}' - only http and https are allowed. "
|
|
115
|
+
f"Got: {url!r}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
hostname = parsed.hostname
|
|
119
|
+
if hostname:
|
|
120
|
+
# Block known cloud metadata hostnames
|
|
121
|
+
if hostname.lower() in _BLOCKED_HOSTS:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Blocked cloud metadata endpoint '{hostname}'. "
|
|
124
|
+
f"Got: {url!r}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Resolve hostname and block private/reserved IP ranges
|
|
128
|
+
try:
|
|
129
|
+
infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
130
|
+
for info in infos:
|
|
131
|
+
addr = info[4][0]
|
|
132
|
+
ip = ipaddress.ip_address(addr)
|
|
133
|
+
if _ip_is_blocked(ip):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"Blocked private/internal IP {addr} (resolved from '{hostname}'). "
|
|
136
|
+
f"Got: {url!r}"
|
|
137
|
+
)
|
|
138
|
+
except socket.gaierror as exc:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"DNS resolution failed for '{hostname}': {exc}. Got: {url!r}"
|
|
141
|
+
) from exc
|
|
142
|
+
|
|
143
|
+
return url
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# SSRF-guarded connections
|
|
148
|
+
#
|
|
149
|
+
# Instead of monkey-patching the process-global socket.getaddrinfo (a
|
|
150
|
+
# non-thread-safe TOCTOU hazard when multiple fetches run concurrently),
|
|
151
|
+
# we subclass the HTTP(S) connection so each connection resolves DNS exactly
|
|
152
|
+
# once, validates the resulting IP, and then connects to that exact IP. There
|
|
153
|
+
# is no second resolution, so a DNS-rebind attack cannot swap in a private
|
|
154
|
+
# address (e.g. 169.254.169.254) between validation and connection.
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _resolve_and_validate(host: str, port: int) -> tuple[int, str]:
|
|
159
|
+
"""Resolve *host* once and return (family, validated_ip) for the first
|
|
160
|
+
address that is not in a blocked range.
|
|
161
|
+
|
|
162
|
+
Raises OSError if every resolved address is private/reserved/internal,
|
|
163
|
+
matching the failure mode urllib/http.client expect from connect().
|
|
164
|
+
"""
|
|
165
|
+
infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
166
|
+
for family, _type, _proto, _canon, sockaddr in infos:
|
|
167
|
+
addr = sockaddr[0]
|
|
168
|
+
try:
|
|
169
|
+
ip = ipaddress.ip_address(addr)
|
|
170
|
+
except ValueError:
|
|
171
|
+
continue
|
|
172
|
+
if _ip_is_blocked(ip):
|
|
173
|
+
raise OSError(
|
|
174
|
+
f"SSRF blocked: IP {addr} resolved from '{host}' is private/reserved"
|
|
175
|
+
)
|
|
176
|
+
return family, addr
|
|
177
|
+
raise OSError(f"SSRF blocked: no usable address resolved from '{host}'")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _SSRFGuardedHTTPConnection(http.client.HTTPConnection):
|
|
181
|
+
"""HTTPConnection that resolves + validates DNS once, then connects to the
|
|
182
|
+
exact validated IP (no second resolution = no DNS-rebind TOCTOU)."""
|
|
183
|
+
|
|
184
|
+
def connect(self) -> None:
|
|
185
|
+
family, ip = _resolve_and_validate(self.host, self.port)
|
|
186
|
+
self.sock = socket.create_connection(
|
|
187
|
+
(ip, self.port),
|
|
188
|
+
self.timeout,
|
|
189
|
+
self.source_address,
|
|
190
|
+
)
|
|
191
|
+
if self._tunnel_host:
|
|
192
|
+
self._tunnel()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _SSRFGuardedHTTPSConnection(http.client.HTTPSConnection):
|
|
196
|
+
"""HTTPSConnection variant of _SSRFGuardedHTTPConnection.
|
|
197
|
+
|
|
198
|
+
Connects to the validated IP but performs the TLS handshake with
|
|
199
|
+
server_hostname set to the original hostname so SNI / certificate
|
|
200
|
+
validation work correctly (validating against the IP would break TLS).
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def connect(self) -> None:
|
|
204
|
+
family, ip = _resolve_and_validate(self.host, self.port)
|
|
205
|
+
sock = socket.create_connection(
|
|
206
|
+
(ip, self.port),
|
|
207
|
+
self.timeout,
|
|
208
|
+
self.source_address,
|
|
209
|
+
)
|
|
210
|
+
if self._tunnel_host:
|
|
211
|
+
self.sock = sock
|
|
212
|
+
self._tunnel()
|
|
213
|
+
sock = self.sock
|
|
214
|
+
self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _SSRFGuardedHTTPHandler(urllib.request.HTTPHandler):
|
|
218
|
+
"""urllib handler that routes http:// through _SSRFGuardedHTTPConnection."""
|
|
219
|
+
|
|
220
|
+
def http_open(self, req):
|
|
221
|
+
return self.do_open(_SSRFGuardedHTTPConnection, req)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class _SSRFGuardedHTTPSHandler(urllib.request.HTTPSHandler):
|
|
225
|
+
"""urllib handler that routes https:// through _SSRFGuardedHTTPSConnection."""
|
|
226
|
+
|
|
227
|
+
def https_open(self, req):
|
|
228
|
+
return self.do_open(_SSRFGuardedHTTPSConnection, req)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _NoFileRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
232
|
+
"""Redirect handler that re-validates every redirect target.
|
|
233
|
+
|
|
234
|
+
Prevents open-redirect SSRF attacks where an http:// URL redirects
|
|
235
|
+
to file:// or an internal address.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
239
|
+
validate_url(newurl) # raises ValueError if scheme is wrong
|
|
240
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _build_opener() -> urllib.request.OpenerDirector:
|
|
244
|
+
# build_opener replaces the default HTTP(S)Handlers with our SSRF-guarded
|
|
245
|
+
# subclasses, so every connection resolves+validates DNS once and connects
|
|
246
|
+
# to that exact IP. Thread-safe: no process-global state is mutated.
|
|
247
|
+
return urllib.request.build_opener(
|
|
248
|
+
_SSRFGuardedHTTPHandler,
|
|
249
|
+
_SSRFGuardedHTTPSHandler,
|
|
250
|
+
_NoFileRedirectHandler,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Safe fetch
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def safe_fetch(url: str, max_bytes: int = _MAX_FETCH_BYTES, timeout: int = 30) -> bytes:
|
|
259
|
+
"""Fetch *url* and return raw bytes.
|
|
260
|
+
|
|
261
|
+
Protections applied:
|
|
262
|
+
- URL scheme validated (http / https only)
|
|
263
|
+
- Redirects re-validated via _NoFileRedirectHandler
|
|
264
|
+
- Response body capped at *max_bytes* (streaming read)
|
|
265
|
+
- Non-2xx status raises urllib.error.HTTPError
|
|
266
|
+
- Network errors propagate as urllib.error.URLError / OSError
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ValueError - disallowed scheme or redirect target
|
|
270
|
+
urllib.error.HTTPError - non-2xx HTTP status
|
|
271
|
+
urllib.error.URLError - DNS / connection failure
|
|
272
|
+
OSError - size cap exceeded
|
|
273
|
+
"""
|
|
274
|
+
validate_url(url)
|
|
275
|
+
opener = _build_opener()
|
|
276
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 graphify/1.0"})
|
|
277
|
+
|
|
278
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
279
|
+
# urllib raises HTTPError for non-2xx when using urlopen directly;
|
|
280
|
+
# with a custom opener we check manually to be safe.
|
|
281
|
+
status = getattr(resp, "status", None) or getattr(resp, "code", None)
|
|
282
|
+
if status is not None and not (200 <= status < 300):
|
|
283
|
+
raise urllib.error.HTTPError(url, status, f"HTTP {status}", {}, None)
|
|
284
|
+
|
|
285
|
+
chunks: list[bytes] = []
|
|
286
|
+
total = 0
|
|
287
|
+
while True:
|
|
288
|
+
chunk = resp.read(65_536)
|
|
289
|
+
if not chunk:
|
|
290
|
+
break
|
|
291
|
+
total += len(chunk)
|
|
292
|
+
if total > max_bytes:
|
|
293
|
+
raise OSError(
|
|
294
|
+
f"Response from {url!r} exceeds size limit "
|
|
295
|
+
f"({max_bytes // 1_048_576} MB). Aborting download."
|
|
296
|
+
)
|
|
297
|
+
chunks.append(chunk)
|
|
298
|
+
|
|
299
|
+
return b"".join(chunks)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def safe_fetch_text(url: str, max_bytes: int = _MAX_TEXT_BYTES, timeout: int = 15) -> str:
|
|
303
|
+
"""Fetch *url* and return decoded text (UTF-8, replacing bad bytes).
|
|
304
|
+
|
|
305
|
+
Wraps safe_fetch with tighter defaults for HTML / text content.
|
|
306
|
+
"""
|
|
307
|
+
raw = safe_fetch(url, max_bytes=max_bytes, timeout=timeout)
|
|
308
|
+
return raw.decode("utf-8", errors="replace")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Path validation
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def validate_graph_path(path: str | Path, base: Path | None = None) -> Path:
|
|
316
|
+
"""Resolve *path* and verify it stays inside *base*.
|
|
317
|
+
|
|
318
|
+
*base* defaults to the `graphify-out` directory relative to CWD.
|
|
319
|
+
Also requires the base directory to exist, so a caller cannot
|
|
320
|
+
trick graphify into reading files before any graph has been built.
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
ValueError - path escapes base, or base does not exist
|
|
324
|
+
FileNotFoundError - resolved path does not exist
|
|
325
|
+
"""
|
|
326
|
+
if base is None:
|
|
327
|
+
resolved_hint = Path(path).resolve()
|
|
328
|
+
for candidate in [resolved_hint, *resolved_hint.parents]:
|
|
329
|
+
if candidate.name == GRAPHIFY_OUT_NAME:
|
|
330
|
+
base = candidate
|
|
331
|
+
break
|
|
332
|
+
if base is None:
|
|
333
|
+
base = Path(GRAPHIFY_OUT).resolve()
|
|
334
|
+
|
|
335
|
+
base = base.resolve()
|
|
336
|
+
if not base.exists():
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"Graph base directory does not exist: {base}. "
|
|
339
|
+
"Run /graphify first to build the graph."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
resolved = Path(path).resolve()
|
|
343
|
+
try:
|
|
344
|
+
resolved.relative_to(base)
|
|
345
|
+
except ValueError:
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f"Path {path!r} escapes the allowed directory {base}. "
|
|
348
|
+
"Only paths inside graphify-out/ are permitted."
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if not resolved.exists():
|
|
352
|
+
raise FileNotFoundError(f"Graph file not found: {resolved}")
|
|
353
|
+
|
|
354
|
+
return resolved
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def check_graph_file_size_cap(path: Path) -> None:
|
|
358
|
+
"""Reject *path* if its size exceeds the configured graph-file cap.
|
|
359
|
+
|
|
360
|
+
Protects callers from memory bombs by failing fast before a multi-GiB
|
|
361
|
+
graph.json is read into memory and JSON-parsed. Silently returns when
|
|
362
|
+
``path.stat()`` cannot be read — the caller's own existence/path check
|
|
363
|
+
is expected to surface a clearer error in that case.
|
|
364
|
+
|
|
365
|
+
The cap is resolved on every call via :func:`_max_graph_file_bytes`, so the
|
|
366
|
+
``GRAPHIFY_MAX_GRAPH_BYTES`` env var can be set before import and still
|
|
367
|
+
apply.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
ValueError - file size exceeds the cap. The message includes the
|
|
371
|
+
observed size, the cap, and how to raise the limit.
|
|
372
|
+
"""
|
|
373
|
+
cap = _max_graph_file_bytes()
|
|
374
|
+
try:
|
|
375
|
+
size = path.stat().st_size
|
|
376
|
+
except OSError:
|
|
377
|
+
return
|
|
378
|
+
if size > cap:
|
|
379
|
+
raise ValueError(
|
|
380
|
+
f"graph file {path} is {size:_d} bytes, exceeds {cap:_d}-byte cap\n"
|
|
381
|
+
f"(set GRAPHIFY_MAX_GRAPH_BYTES=<bytes> or "
|
|
382
|
+
f"GRAPHIFY_MAX_GRAPH_BYTES=<N>GB to raise the limit)"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Label sanitisation (mirrors code-review-graph's _sanitize_name pattern)
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
391
|
+
_MAX_LABEL_LEN = 256
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def sanitize_label(text: str | None) -> str:
|
|
395
|
+
"""Strip control characters and cap length.
|
|
396
|
+
|
|
397
|
+
Safe for embedding in JSON data (inside <script> tags) and plain text.
|
|
398
|
+
For direct HTML injection, wrap the result with html.escape().
|
|
399
|
+
"""
|
|
400
|
+
if text is None:
|
|
401
|
+
return ""
|
|
402
|
+
text = _CONTROL_CHAR_RE.sub("", str(text))
|
|
403
|
+
if len(text) > _MAX_LABEL_LEN:
|
|
404
|
+
text = text[:_MAX_LABEL_LEN]
|
|
405
|
+
return text
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
# Metadata sanitisation (recursive, bounded, HTML-safe)
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
_METADATA_MAX_VALUE_LEN = 512
|
|
413
|
+
_METADATA_MAX_LIST_ITEMS = 50
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _sanitize_metadata_string(value: object) -> str:
|
|
417
|
+
"""Return a control-character-free, HTML-escaped, bounded string."""
|
|
418
|
+
text = _CONTROL_CHAR_RE.sub("", str(value))
|
|
419
|
+
text = html.escape(text, quote=True)
|
|
420
|
+
if len(text) > _METADATA_MAX_VALUE_LEN:
|
|
421
|
+
text = text[:_METADATA_MAX_VALUE_LEN]
|
|
422
|
+
return text # html is imported at module level (line 5)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _sanitize_metadata_value(value: object) -> object:
|
|
426
|
+
"""Sanitize a metadata value while preserving simple JSON-compatible types."""
|
|
427
|
+
if isinstance(value, bool):
|
|
428
|
+
# bool is a subclass of int — must be checked first to avoid coercion.
|
|
429
|
+
return value
|
|
430
|
+
if isinstance(value, str):
|
|
431
|
+
return _sanitize_metadata_string(value)
|
|
432
|
+
if isinstance(value, dict):
|
|
433
|
+
return sanitize_metadata(value)
|
|
434
|
+
if isinstance(value, (list, tuple)):
|
|
435
|
+
return [_sanitize_metadata_value(item) for item in value[:_METADATA_MAX_LIST_ITEMS]]
|
|
436
|
+
if isinstance(value, (int, float)) or value is None:
|
|
437
|
+
return value
|
|
438
|
+
return _sanitize_metadata_string(value)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def sanitize_metadata(metadata: Mapping[str, Any] | None) -> dict[str, object]:
|
|
442
|
+
"""Sanitize metadata keys and values before graph export.
|
|
443
|
+
|
|
444
|
+
Metadata is less constrained than node labels: it can contain nested
|
|
445
|
+
dicts, lists, source snippets, external index symbols, and docstring
|
|
446
|
+
text. This helper keeps the data JSON-compatible, strips control
|
|
447
|
+
characters, escapes HTML-sensitive characters in strings, caps long
|
|
448
|
+
strings/lists, and drops entries whose key becomes empty after
|
|
449
|
+
sanitization.
|
|
450
|
+
"""
|
|
451
|
+
if metadata is None:
|
|
452
|
+
return {}
|
|
453
|
+
|
|
454
|
+
result: dict[str, object] = {}
|
|
455
|
+
for key, value in metadata.items():
|
|
456
|
+
clean_key = _sanitize_metadata_string(key)
|
|
457
|
+
if not clean_key:
|
|
458
|
+
continue
|
|
459
|
+
result[clean_key] = _sanitize_metadata_value(value)
|
|
460
|
+
return result
|