cg-code-graph 0.10.1__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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
codegraph/external.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""External systems (#40, epic #30): databases, caches, brokers, mail relays, directories, file-transfer hosts and
|
|
2
|
+
object stores the code connects to, as `external:<protocol>:<target>` nodes.
|
|
3
|
+
|
|
4
|
+
target `host:port` when the address is known (a literal DSN, a `.env.example` value naming a docker-compose
|
|
5
|
+
service or a non-loopback host), otherwise `env:<KEY>` (the address comes from the environment; two repos
|
|
6
|
+
reading the same key meet on one node in `cg link`)
|
|
7
|
+
attrs protocol, scheme, host, port, resource, tls (true / false / null), address_source (literal | env-example |
|
|
8
|
+
compose | env | config) + address_at, address_default (a framework / example default that is not used as
|
|
9
|
+
the target, such as 127.0.0.1), credential_source (env | config | literal | none | unknown) + credential_at,
|
|
10
|
+
user (from a DSN), deployment_name / image (docker-compose), library
|
|
11
|
+
edges CONNECTS_TO (code or a logical connection -> external, propagating; attrs.op, via), CONFIGURED_BY
|
|
12
|
+
(external -> env / config node of the address), CREDENTIAL_FROM (external -> env / config node of the
|
|
13
|
+
password / token)
|
|
14
|
+
|
|
15
|
+
Sources, language-agnostic over the built graph:
|
|
16
|
+
* env keys read by code (env nodes, READS_ENV), grouped by prefix (DB_*, DATABASE_URL, REDIS_*, MAIL_* / SMTP_*,
|
|
17
|
+
MONGO*_*, RABBITMQ_* / AMQP_*, ELASTICSEARCH_*, MEMCACHED_*, LDAP_*, SFTP_* / SSH_*, FTP_*, S3_*); every reader of
|
|
18
|
+
an address key connects;
|
|
19
|
+
* Laravel `connection` nodes (config/database.php): the default connection, used ones and custom ones attach to
|
|
20
|
+
the external of their driver and host keys (the framework's unused template connections do not);
|
|
21
|
+
* `.env.example` / `.env.sample` / `.env.dist` values and `docker-compose*.yml` / `compose*.yaml` services resolve
|
|
22
|
+
the target (`DB_HOST=db` + a `db` service running postgres -> external:postgres:db:5432).
|
|
23
|
+
Secret values (passwords, tokens, DSN passwords) are never read into the graph: only the key and its location.
|
|
24
|
+
Third-party HTTP origins (`http` nodes with origin_kind = other) are shown by `cg external` as `https` systems
|
|
25
|
+
without extra nodes.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from urllib.parse import unquote, urlsplit
|
|
33
|
+
|
|
34
|
+
SCHEMES = {
|
|
35
|
+
"postgres": "postgres", "postgresql": "postgres", "pgsql": "postgres", "postgis": "postgres",
|
|
36
|
+
"mysql": "mysql", "mariadb": "mysql", "mysql2": "mysql", "sqlsrv": "mssql", "mssql": "mssql", "sqlserver": "mssql",
|
|
37
|
+
"oracle": "oracle", "mongodb": "mongodb", "mongodb+srv": "mongodb", "redis": "redis", "rediss": "redis",
|
|
38
|
+
"valkey": "redis", "amqp": "amqp", "amqps": "amqp", "smtp": "smtp", "smtps": "smtp", "submission": "smtp",
|
|
39
|
+
"ldap": "ldap", "ldaps": "ldap", "sftp": "ssh", "ssh": "ssh", "scp": "ssh", "ftp": "ftp", "ftps": "ftp",
|
|
40
|
+
"s3": "s3", "memcached": "memcached", "memcache": "memcached", "elasticsearch": "elasticsearch",
|
|
41
|
+
"nats": "nats", "kafka": "kafka", "mqtt": "mqtt", "mqtts": "mqtt", "imap": "imap", "imaps": "imap",
|
|
42
|
+
"pop3": "pop3", "pop3s": "pop3", "clickhouse": "clickhouse", "cassandra": "cassandra",
|
|
43
|
+
}
|
|
44
|
+
TLS_SCHEMES = {"rediss", "amqps", "smtps", "ldaps", "ftps", "mqtts", "imaps", "pop3s", "https", "mongodb+srv", "sftp", "ssh",
|
|
45
|
+
"scp", "s3"}
|
|
46
|
+
PLAIN_SCHEMES = {"redis", "amqp", "smtp", "ldap", "ftp", "mqtt", "imap", "pop3", "http"}
|
|
47
|
+
PORTS = {"postgres": 5432, "mysql": 3306, "mssql": 1433, "oracle": 1521, "mongodb": 27017, "redis": 6379, "amqp": 5672,
|
|
48
|
+
"smtp": 587, "ldap": 389, "ssh": 22, "ftp": 21, "memcached": 11211, "elasticsearch": 9200, "nats": 4222,
|
|
49
|
+
"kafka": 9092, "mqtt": 1883, "imap": 143, "pop3": 110, "clickhouse": 9000, "cassandra": 9042, "https": 443}
|
|
50
|
+
SCHEME_PORTS = {"amqps": 5671, "smtps": 465, "ldaps": 636, "ftps": 990, "mqtts": 8883, "imaps": 993, "pop3s": 995}
|
|
51
|
+
TLS_PORTS = {"smtp": {465}, "ldap": {636}, "imap": {993}, "pop3": {995}, "redis": set(), "amqp": {5671}, "mqtt": {8883},
|
|
52
|
+
"ftp": {990}}
|
|
53
|
+
DRIVERS = {"pgsql": "postgres", "mysql": "mysql", "mariadb": "mysql", "sqlsrv": "mssql", "oracle": "oracle",
|
|
54
|
+
"mongodb": "mongodb", "redis": "redis", "sqlite": None}
|
|
55
|
+
# env key prefix -> protocol (None: from DB_CONNECTION / the URL scheme)
|
|
56
|
+
PREFIXES = [("DATABASE", None), ("DB", None), ("POSTGRES", "postgres"), ("POSTGRESQL", "postgres"), ("PG", "postgres"),
|
|
57
|
+
("MYSQL", "mysql"), ("MARIADB", "mysql"), ("MSSQL", "mssql"), ("REDIS", "redis"), ("VALKEY", "redis"),
|
|
58
|
+
("MAIL", "smtp"), ("SMTP", "smtp"), ("EMAIL", "smtp"), ("MONGO", "mongodb"), ("MONGODB", "mongodb"),
|
|
59
|
+
("RABBITMQ", "amqp"), ("RABBIT", "amqp"), ("AMQP", "amqp"), ("BROKER", None), ("ELASTICSEARCH", "elasticsearch"),
|
|
60
|
+
("OPENSEARCH", "elasticsearch"), ("ELASTIC", "elasticsearch"), ("MEMCACHED", "memcached"), ("LDAP", "ldap"),
|
|
61
|
+
("SFTP", "ssh"), ("SSH", "ssh"), ("FTP", "ftp"), ("S3", "s3"), ("MINIO", "s3"), ("NATS", "nats"),
|
|
62
|
+
("KAFKA", "kafka"), ("MQTT", "mqtt"), ("IMAP", "imap"), ("CLICKHOUSE", "clickhouse")]
|
|
63
|
+
ADDRESS = ("URL", "URI", "DSN", "HOST", "HOSTNAME", "SERVER", "ENDPOINT", "ADDR", "ADDRESS", "BROKERS")
|
|
64
|
+
SECRET = ("PASSWORD", "PASS", "PASSWD", "SECRET", "TOKEN", "SECRET_KEY", "SECRET_ACCESS_KEY", "KEY", "AUTH")
|
|
65
|
+
IMAGES = [("postgis", "postgres"), ("postgres", "postgres"), ("pgvector", "postgres"), ("timescale", "postgres"),
|
|
66
|
+
("mariadb", "mysql"), ("mysql", "mysql"), ("mssql", "mssql"), ("valkey", "redis"), ("redis", "redis"),
|
|
67
|
+
("mongo", "mongodb"), ("rabbitmq", "amqp"), ("elasticsearch", "elasticsearch"), ("opensearch", "elasticsearch"),
|
|
68
|
+
("memcached", "memcached"), ("minio", "s3"), ("localstack", "s3"), ("mailhog", "smtp"), ("mailpit", "smtp"),
|
|
69
|
+
("maildev", "smtp"), ("openldap", "ldap"), ("nats", "nats"), ("kafka", "kafka"), ("mosquitto", "mqtt"),
|
|
70
|
+
("clickhouse", "clickhouse"), ("cassandra", "cassandra")]
|
|
71
|
+
LLM_KEYS = {"openai": ("OPENAI_API_KEY",), "azure-openai": ("AZURE_OPENAI_API_KEY", "AZURE_OPENAI_KEY"),
|
|
72
|
+
"anthropic": ("ANTHROPIC_API_KEY",), "ollama": (), "openai-agents": ("OPENAI_API_KEY",)}
|
|
73
|
+
LOOPBACK = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal", ""}
|
|
74
|
+
ENV_FILES = (".env.example", ".env.sample", ".env.dist", ".env.template", ".env.defaults", "example.env", "env.example")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ DSN / URL parsing
|
|
78
|
+
def parse_dsn(url: str) -> dict | None:
|
|
79
|
+
"""`postgres://user:pw@db:5432/app?sslmode=require`, `jdbc:mysql://h/db`, `rediss://:pw@cache:6380/0` ... ->
|
|
80
|
+
{protocol, scheme, host, port, resource, user, has_password, tls}; None when it is not a known scheme or has no
|
|
81
|
+
host. The password is never returned."""
|
|
82
|
+
if not url or "://" not in url:
|
|
83
|
+
return None
|
|
84
|
+
u = url.strip()
|
|
85
|
+
if u.lower().startswith("jdbc:"):
|
|
86
|
+
u = u[5:]
|
|
87
|
+
scheme = u.split("://", 1)[0].lower()
|
|
88
|
+
base = scheme.split("+")[0] if scheme not in SCHEMES else scheme
|
|
89
|
+
proto = SCHEMES.get(scheme) or SCHEMES.get(base) or ("https" if base in ("http", "https") else None)
|
|
90
|
+
if proto is None:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
p = urlsplit(u)
|
|
94
|
+
host = (p.hostname or "").lower()
|
|
95
|
+
port = p.port
|
|
96
|
+
except ValueError:
|
|
97
|
+
return None
|
|
98
|
+
if not host or "{" in host or "$" in host:
|
|
99
|
+
return None
|
|
100
|
+
tls = True if scheme in TLS_SCHEMES or re.search(r"ssl(mode)?=(require|verify|true|1)|tls=true", p.query or "", re.I) \
|
|
101
|
+
else False if scheme in PLAIN_SCHEMES else None
|
|
102
|
+
res = unquote(p.path.lstrip("/")) or None
|
|
103
|
+
return {"protocol": proto, "scheme": scheme, "host": host, "port": port or SCHEME_PORTS.get(scheme) or PORTS.get(proto), "resource": res,
|
|
104
|
+
"user": unquote(p.username) if p.username else None, "has_password": bool(p.password), "tls": tls}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def target_of(host: str, port) -> str:
|
|
108
|
+
return f"{host}:{port}" if port else host
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------ project files
|
|
112
|
+
def read_env_example(root: Path) -> dict:
|
|
113
|
+
"""Non-secret values of the example env files at the root: {KEY: (value, file:line)}; secret keys keep only
|
|
114
|
+
their location (value None); a DSN password becomes `***`."""
|
|
115
|
+
out = {}
|
|
116
|
+
for name in ENV_FILES:
|
|
117
|
+
f = root / name
|
|
118
|
+
if not f.is_file():
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
lines = f.read_text(errors="replace").splitlines()
|
|
122
|
+
except OSError:
|
|
123
|
+
continue
|
|
124
|
+
for i, ln in enumerate(lines, 1):
|
|
125
|
+
m = re.match(r"\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$", ln)
|
|
126
|
+
if not m or m.group(1) in out:
|
|
127
|
+
continue
|
|
128
|
+
k, v = m.group(1), m.group(2).strip()
|
|
129
|
+
if v[:1] in "\"'" and v[-1:] == v[:1]:
|
|
130
|
+
v = v[1:-1]
|
|
131
|
+
elif " #" in v:
|
|
132
|
+
v = v.split(" #", 1)[0].strip()
|
|
133
|
+
if "://" in v: # DSN password -> placeholder (presence kept, value dropped)
|
|
134
|
+
v = re.sub(r"(://[^:/@\s]*):[^@/\s]+@", r"\1:***@", v)
|
|
135
|
+
out[k] = (None if _secret(k) else v, f"{name}:{i}")
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def read_compose(root: Path) -> dict:
|
|
140
|
+
"""docker-compose services with a known image: {service: {protocol, image, port, file}}."""
|
|
141
|
+
out = {}
|
|
142
|
+
files = sorted({*root.glob("docker-compose*.y*ml"), *root.glob("compose*.y*ml"), *root.glob("docker/docker-compose*.y*ml")})
|
|
143
|
+
if not files:
|
|
144
|
+
return out
|
|
145
|
+
try:
|
|
146
|
+
import yaml
|
|
147
|
+
except ImportError:
|
|
148
|
+
return out
|
|
149
|
+
for f in files:
|
|
150
|
+
try:
|
|
151
|
+
d = yaml.safe_load(f.read_text(errors="replace")) or {}
|
|
152
|
+
except Exception: # noqa: BLE001
|
|
153
|
+
continue
|
|
154
|
+
for svc, spec in ((d.get("services") or {}) if isinstance(d, dict) else {}).items():
|
|
155
|
+
if not isinstance(spec, dict):
|
|
156
|
+
continue
|
|
157
|
+
img = str(spec.get("image") or "")
|
|
158
|
+
name = img.rsplit("/", 1)[-1].split(":", 1)[0].lower() or str(svc).lower()
|
|
159
|
+
proto = next((p for k, p in IMAGES if k in name), None)
|
|
160
|
+
if proto is None:
|
|
161
|
+
continue
|
|
162
|
+
out[str(svc)] = {"protocol": proto, "image": img or None, "port": PORTS.get(proto),
|
|
163
|
+
"file": str(f.relative_to(root))}
|
|
164
|
+
for alias in ((spec.get("container_name"),) if spec.get("container_name") else ()):
|
|
165
|
+
out.setdefault(str(alias), out[str(svc)])
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _secret(key: str) -> bool:
|
|
170
|
+
k = key.upper()
|
|
171
|
+
return any(k.endswith("_" + s) or k == s for s in SECRET) and not k.endswith(("_KEY_ID", "_PUBLIC_KEY"))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _split(key: str):
|
|
175
|
+
"""DB_HOST -> ('DB', 'HOST'); DATABASE_URL -> ('DATABASE', 'URL'); REDIS_CACHE_HOST -> ('REDIS_CACHE', 'HOST')."""
|
|
176
|
+
k = key.upper()
|
|
177
|
+
for pre, proto in sorted(PREFIXES, key=lambda x: -len(x[0])):
|
|
178
|
+
if k == pre or k.startswith(pre + "_"):
|
|
179
|
+
rest = k[len(pre) + 1:]
|
|
180
|
+
parts = rest.split("_") if rest else []
|
|
181
|
+
if not parts:
|
|
182
|
+
return None
|
|
183
|
+
role = "_".join(parts[-2:]) if "_".join(parts[-2:]) in SECRET else parts[-1]
|
|
184
|
+
group = "_".join([pre, *parts[:-len(role.split("_"))]])
|
|
185
|
+
return pre, proto, group, role
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
SQL_DEPS = {"pg": "postgres", "postgres": "postgres", "pg-promise": "postgres", "@neondatabase/serverless": "postgres",
|
|
190
|
+
"mysql": "mysql", "mysql2": "mysql", "mariadb": "mysql", "mssql": "mssql", "tedious": "mssql",
|
|
191
|
+
"oracledb": "oracle", "psycopg": "postgres", "psycopg2": "postgres", "psycopg2-binary": "postgres",
|
|
192
|
+
"asyncpg": "postgres", "mysqlclient": "mysql", "pymysql": "mysql", "aiomysql": "mysql", "pyodbc": "mssql",
|
|
193
|
+
"cx_oracle": "oracle"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def dep_sql_protocol(root: Path, compose: dict | None = None) -> str | None:
|
|
197
|
+
"""The one SQL protocol a project's dependencies (package.json, requirements*.txt, pyproject.toml) or compose
|
|
198
|
+
services point to, for `DB_*` / `DATABASE_URL` keys without a driver or URL scheme; None when none or several."""
|
|
199
|
+
found = set()
|
|
200
|
+
pj = root / "package.json"
|
|
201
|
+
if pj.is_file():
|
|
202
|
+
try:
|
|
203
|
+
d = json.loads(pj.read_text(errors="replace"))
|
|
204
|
+
for sec in ("dependencies", "devDependencies", "optionalDependencies"):
|
|
205
|
+
found |= {SQL_DEPS[k.lower()] for k in (d.get(sec) or {}) if k.lower() in SQL_DEPS}
|
|
206
|
+
except (OSError, ValueError, AttributeError):
|
|
207
|
+
pass
|
|
208
|
+
for f in [*root.glob("requirements*.txt"), root / "pyproject.toml"]:
|
|
209
|
+
if f.is_file():
|
|
210
|
+
try:
|
|
211
|
+
txt = f.read_text(errors="replace").lower()
|
|
212
|
+
except OSError:
|
|
213
|
+
continue
|
|
214
|
+
found |= {v for k, v in SQL_DEPS.items() if re.search(rf"(^|[\s\"'\[])({re.escape(k)})\s*([<>=~!\[;\"',]|$)", txt, re.M)}
|
|
215
|
+
if not found and compose:
|
|
216
|
+
found = {c["protocol"] for c in compose.values() if c.get("protocol") in ("postgres", "mysql", "mssql", "oracle")}
|
|
217
|
+
return found.pop() if len(found) == 1 else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------ index-time pass
|
|
221
|
+
def attach(builder, root: Path) -> dict:
|
|
222
|
+
root = Path(root)
|
|
223
|
+
envs = {n.id[len("env:"):]: n for n in builder.nodes.values() if n.kind == "env"}
|
|
224
|
+
conns = [n for n in builder.nodes.values() if n.kind == "connection"]
|
|
225
|
+
facts = getattr(builder, "external_facts", None) or []
|
|
226
|
+
# model calls made from test code are fixtures, not systems the application talks to (as for third-party HTTP)
|
|
227
|
+
llm = [n for n in builder.nodes.values() if (n.attrs or {}).get("llm_calls") and not n.attrs.get("test")]
|
|
228
|
+
if not envs and not conns and not facts and not llm:
|
|
229
|
+
return {}
|
|
230
|
+
ex = read_env_example(root)
|
|
231
|
+
compose = read_compose(root)
|
|
232
|
+
readers: dict = {}
|
|
233
|
+
for e in builder.edges.values():
|
|
234
|
+
if e.kind == "READS_ENV":
|
|
235
|
+
readers.setdefault(e.dst, []).append(e)
|
|
236
|
+
st = {"systems": 0, "connects": 0, "from_connections": 0, "compose_services": len(compose)}
|
|
237
|
+
|
|
238
|
+
def val(key):
|
|
239
|
+
v = ex.get(key)
|
|
240
|
+
return v[0] if v else None
|
|
241
|
+
|
|
242
|
+
def resolve(proto, host_key=None, port_key=None, url_key=None, host_val=None, port_val=None, src_hint=None):
|
|
243
|
+
"""(target, attrs, confidence) for an address."""
|
|
244
|
+
a = {}
|
|
245
|
+
if url_key:
|
|
246
|
+
d = parse_dsn(val(url_key) or "")
|
|
247
|
+
if d:
|
|
248
|
+
proto = proto or d["protocol"]
|
|
249
|
+
a.update({k: d[k] for k in ("scheme", "resource", "user", "tls") if d.get(k) is not None})
|
|
250
|
+
a["credential_in_url"] = d["has_password"] or None
|
|
251
|
+
host, port = d["host"], d["port"]
|
|
252
|
+
src = ("env-example", ex[url_key][1])
|
|
253
|
+
else:
|
|
254
|
+
host, port, src = None, None, None
|
|
255
|
+
else:
|
|
256
|
+
host = (host_val if host_val is not None else val(host_key) if host_key else None)
|
|
257
|
+
pv = port_val if port_val is not None else val(port_key) if port_key else None
|
|
258
|
+
port = int(pv) if pv and str(pv).isdigit() else None
|
|
259
|
+
src = ("env-example", ex[host_key][1]) if host_key and val(host_key) else (src_hint if host_val else None)
|
|
260
|
+
if proto is None:
|
|
261
|
+
return None
|
|
262
|
+
if host and host.lower() in compose:
|
|
263
|
+
c = compose[host.lower()]
|
|
264
|
+
a.update({"deployment_name": host.lower(), "image": c["image"], "compose_file": c["file"]})
|
|
265
|
+
port = port or c["port"]
|
|
266
|
+
return target_of(host.lower(), port or PORTS.get(proto)), {**a, "address_source": "compose",
|
|
267
|
+
"address_at": src[1] if src else c["file"]}, "resolved", proto
|
|
268
|
+
if host and host.lower() not in LOOPBACK and re.match(r"^[A-Za-z0-9.\-]+$", host):
|
|
269
|
+
return target_of(host.lower(), port or PORTS.get(proto)), {**a, "address_source": src[0] if src else "literal",
|
|
270
|
+
"address_at": src[1] if src else None}, \
|
|
271
|
+
"resolved" if src and src[0] == "env-example" else "exact", proto
|
|
272
|
+
key = url_key or host_key
|
|
273
|
+
if key is None:
|
|
274
|
+
return None
|
|
275
|
+
if host:
|
|
276
|
+
a["address_default"] = target_of(host.lower(), port or PORTS.get(proto))
|
|
277
|
+
return f"env:{key}", {**a, "address_source": "env"}, "heuristic", proto
|
|
278
|
+
|
|
279
|
+
def node(proto, target, attrs, conf):
|
|
280
|
+
nid = builder.add_node("external", f"{proto}:{target}", f"{proto}:{target}", fqn=f"{proto}:{target}",
|
|
281
|
+
attrs={"protocol": proto, "target": target, "confidence": conf})
|
|
282
|
+
n = builder.nodes[nid]
|
|
283
|
+
for k, v in attrs.items():
|
|
284
|
+
if v not in (None, "", []):
|
|
285
|
+
n.attrs.setdefault(k, v)
|
|
286
|
+
if ":" in target and not target.startswith(("env:", "config:")):
|
|
287
|
+
h, _, p = target.rpartition(":")
|
|
288
|
+
n.attrs.setdefault("host", h)
|
|
289
|
+
if p.isdigit():
|
|
290
|
+
n.attrs.setdefault("port", int(p))
|
|
291
|
+
if n.attrs.get("tls") is None and int(p) in TLS_PORTS.get(proto, set()):
|
|
292
|
+
n.attrs["tls"] = True
|
|
293
|
+
if nid not in seen:
|
|
294
|
+
seen.add(nid)
|
|
295
|
+
st["systems"] += 1
|
|
296
|
+
return nid
|
|
297
|
+
|
|
298
|
+
seen: set = set()
|
|
299
|
+
used_keys: set = set()
|
|
300
|
+
|
|
301
|
+
def cred(nid, keys):
|
|
302
|
+
for k in keys:
|
|
303
|
+
eid = f"env:{k}"
|
|
304
|
+
if eid in builder.nodes:
|
|
305
|
+
builder.add_edge(nid, eid, "CREDENTIAL_FROM", None, None, "resolved", secret_kind=_cred_kind(k))
|
|
306
|
+
builder.nodes[nid].attrs.setdefault("credential_source", "env")
|
|
307
|
+
builder.nodes[nid].attrs.setdefault("credential_at", f"env:{k}")
|
|
308
|
+
|
|
309
|
+
# ---- Laravel connections (config/database.php) -> external of their driver and host
|
|
310
|
+
cfg = {n.id[len("config:"):]: n for n in builder.nodes.values() if n.kind == "config"}
|
|
311
|
+
default = None
|
|
312
|
+
dn = cfg.get("database.default")
|
|
313
|
+
if dn is not None:
|
|
314
|
+
da = dn.attrs or {}
|
|
315
|
+
envk = (da.get("env") or [None])[0]
|
|
316
|
+
default = (val(envk) if envk else None) or (da.get("env_default") or [None])[0] or da.get("value")
|
|
317
|
+
used = {e.dst for e in builder.edges.values() if e.kind == "USES_CONNECTION"}
|
|
318
|
+
templates = {"sqlite", "mysql", "mariadb", "pgsql", "sqlsrv"}
|
|
319
|
+
for c in conns:
|
|
320
|
+
name = c.id.split(":", 1)[1]
|
|
321
|
+
if name in templates and name != default and c.id not in used:
|
|
322
|
+
continue
|
|
323
|
+
base = f"database.connections.{name}"
|
|
324
|
+
|
|
325
|
+
def leaf(k):
|
|
326
|
+
n = cfg.get(f"{base}.{k}")
|
|
327
|
+
a = (n.attrs or {}) if n else {}
|
|
328
|
+
ek = (a.get("env") or [None])[0]
|
|
329
|
+
return ek, (val(ek) if ek and val(ek) else None) or (a.get("value") if "value" in a else None) or \
|
|
330
|
+
((a.get("env_default") or [None])[0]), n
|
|
331
|
+
drv = (leaf("driver")[1]) or name
|
|
332
|
+
proto = DRIVERS.get(str(drv).lower(), SCHEMES.get(str(drv).lower()))
|
|
333
|
+
if proto is None:
|
|
334
|
+
continue
|
|
335
|
+
hk, hv, hn = leaf("host")
|
|
336
|
+
pk, pv, _ = leaf("port")
|
|
337
|
+
uk, uv, _ = leaf("url")
|
|
338
|
+
r = None
|
|
339
|
+
if uk and val(uk):
|
|
340
|
+
r = resolve(proto, url_key=uk)
|
|
341
|
+
if r is None:
|
|
342
|
+
if hk:
|
|
343
|
+
r = resolve(proto, host_key=hk, port_key=pk) or None
|
|
344
|
+
if r and r[0].startswith("env:") and hv:
|
|
345
|
+
pass
|
|
346
|
+
elif hv:
|
|
347
|
+
r = resolve(proto, host_val=str(hv), port_val=pv, src_hint=("config", f"config:{base}.host"))
|
|
348
|
+
if r is None:
|
|
349
|
+
continue
|
|
350
|
+
target, attrs, conf, proto = r
|
|
351
|
+
nid = node(proto, target, {**attrs, "library": "laravel", "connection": name}, conf)
|
|
352
|
+
builder.add_edge(c.id, nid, "CONNECTS_TO", c.file, c.line, conf, op="query", via=f"connection {name}")
|
|
353
|
+
if hn is not None:
|
|
354
|
+
builder.add_edge(nid, hn.id, "CONFIGURED_BY", None, None, conf)
|
|
355
|
+
if hk:
|
|
356
|
+
used_keys.add(hk)
|
|
357
|
+
if f"env:{hk}" in builder.nodes:
|
|
358
|
+
builder.add_edge(nid, f"env:{hk}", "CONFIGURED_BY", None, None, conf)
|
|
359
|
+
for k in ("password", "username"):
|
|
360
|
+
ck, _cv, cn = leaf(k)
|
|
361
|
+
if k == "password" and ck:
|
|
362
|
+
cred(nid, [ck])
|
|
363
|
+
used_keys.add(ck)
|
|
364
|
+
elif k == "password" and cn is not None and (cn.attrs or {}).get("value"):
|
|
365
|
+
builder.nodes[nid].attrs["credential_source"] = "literal"
|
|
366
|
+
builder.nodes[nid].attrs["credential_at"] = cn.id
|
|
367
|
+
st["from_connections"] += 1
|
|
368
|
+
|
|
369
|
+
# ---- facts from language plugins (Python settings dicts / URLs)
|
|
370
|
+
defaults_at: set = set()
|
|
371
|
+
|
|
372
|
+
def code_default(key, default, f):
|
|
373
|
+
"""`os.environ.get("K", "db")`: the in-code default stands in for a missing .env.example value."""
|
|
374
|
+
if default and key not in ex and not _secret(key):
|
|
375
|
+
at = f"{f['file']}:{f['line']}"
|
|
376
|
+
ex[key] = (default, at)
|
|
377
|
+
defaults_at.add(at)
|
|
378
|
+
|
|
379
|
+
for f in facts:
|
|
380
|
+
u = f.get("url")
|
|
381
|
+
r = None
|
|
382
|
+
if u is not None:
|
|
383
|
+
if u[0] == "lit":
|
|
384
|
+
d = parse_dsn(u[1])
|
|
385
|
+
if d and f.get("client") and (d["host"] or "").lower() in LOOPBACK:
|
|
386
|
+
continue # redis.from_url("redis://localhost"): no system
|
|
387
|
+
if d:
|
|
388
|
+
# an HTTP endpoint of a client that speaks another protocol over it (boto3 S3 endpoint_url)
|
|
389
|
+
pr = f["protocol"] if f.get("client") and d["protocol"] in ("http", "https") and f["protocol"] else d["protocol"]
|
|
390
|
+
r = (target_of(d["host"], d["port"]), {"address_source": "literal", "address_at": f"{f['file']}:{f['line']}",
|
|
391
|
+
**{k: d[k] for k in ("scheme", "resource", "user", "tls") if d.get(k) is not None}}, "exact", pr)
|
|
392
|
+
if d["has_password"]:
|
|
393
|
+
r[1].update(credential_source="literal", credential_at=f"{f['file']}:{f['line']}")
|
|
394
|
+
elif f.get("host_only") and f["protocol"]:
|
|
395
|
+
r = resolve(f["protocol"], host_val=u[1], src_hint=("literal", f"{f['file']}:{f['line']}"))
|
|
396
|
+
else:
|
|
397
|
+
code_default(u[1], u[2], f)
|
|
398
|
+
r = resolve(f["protocol"], url_key=u[1]) if not f.get("host_only") else resolve(f["protocol"], host_key=u[1])
|
|
399
|
+
if r is None and f["protocol"]:
|
|
400
|
+
r = (f"env:{u[1]}", {"address_source": "env"}, "heuristic", f["protocol"])
|
|
401
|
+
else:
|
|
402
|
+
h, p = f.get("host"), f.get("port")
|
|
403
|
+
pv = p[1] if p and p[0] == "lit" else (p[2] if p and p[0] == "env" else None)
|
|
404
|
+
proto = f["protocol"] or (dep_sql_protocol(root, compose) if f["var"].startswith("DATABASE") else None) or "sql"
|
|
405
|
+
if h[0] == "lit":
|
|
406
|
+
r = resolve(proto, host_val=h[1], port_val=pv, src_hint=("literal", f"{f['file']}:{f['line']}"))
|
|
407
|
+
if f.get("client") and (r is None or r[0].startswith("env:")):
|
|
408
|
+
continue # a client pointed at the local machine: no system
|
|
409
|
+
if r is None or r[0].startswith("env:"):
|
|
410
|
+
r = (f"config:{f['module']}.{f['var']}", {"address_source": "config", "address_at": f"{f['file']}:{f['line']}",
|
|
411
|
+
"address_default": target_of(h[1] or "localhost", pv or PORTS.get(proto))}, "heuristic", proto)
|
|
412
|
+
elif h[0] == "env":
|
|
413
|
+
code_default(h[1], h[2], f)
|
|
414
|
+
r = resolve(proto, host_key=h[1], port_val=pv)
|
|
415
|
+
if r is None:
|
|
416
|
+
continue
|
|
417
|
+
target, attrs, conf, proto = r
|
|
418
|
+
if attrs.get("address_source") == "env-example" and attrs.get("address_at") in defaults_at:
|
|
419
|
+
attrs = {**attrs, "address_source": "code-default"}
|
|
420
|
+
if f.get("client"): # a client constructor in code (#77)
|
|
421
|
+
nid = node(proto, target, {**attrs, "client": f["var"], "library": f["client"], "resource": f.get("resource")}, conf)
|
|
422
|
+
builder.add_edge(f["src"], nid, "CONNECTS_TO", f["file"], f["line"], conf, op="connect", via=f"client {f['var']}")
|
|
423
|
+
else:
|
|
424
|
+
nid = node(proto, target, {**attrs, "setting": f["var"], "resource": f.get("resource")}, conf)
|
|
425
|
+
builder.add_edge(f["src"], nid, "CONNECTS_TO", f["file"], f["line"], conf, op="configure", via=f"setting {f['var']}")
|
|
426
|
+
st["connects"] += 1
|
|
427
|
+
for kk in ([u[1]] if u is not None and u[0] == "env" else []) + ([f["host"][1]] if f.get("host") and f["host"][0] == "env" else []):
|
|
428
|
+
if f"env:{kk}" in builder.nodes:
|
|
429
|
+
builder.add_edge(nid, f"env:{kk}", "CONFIGURED_BY", None, None, conf)
|
|
430
|
+
used_keys.add(kk)
|
|
431
|
+
pw = f.get("password")
|
|
432
|
+
if pw and pw[0] == "env":
|
|
433
|
+
cred(nid, [pw[1]])
|
|
434
|
+
builder.nodes[nid].attrs.setdefault("credential_source", "env")
|
|
435
|
+
builder.nodes[nid].attrs.setdefault("credential_at", f"env:{pw[1]}")
|
|
436
|
+
used_keys.add(pw[1])
|
|
437
|
+
elif pw:
|
|
438
|
+
builder.nodes[nid].attrs.update(credential_source="literal", credential_at=pw[1])
|
|
439
|
+
|
|
440
|
+
# ---- model calls (#66 attrs.llm_calls) -> external:llm:<provider>, the API-key env var as the credential (#77)
|
|
441
|
+
for n in llm:
|
|
442
|
+
for c in n.attrs["llm_calls"]:
|
|
443
|
+
prov = c.get("provider")
|
|
444
|
+
if not prov:
|
|
445
|
+
continue
|
|
446
|
+
base = parse_dsn(c["base_url"]) if c.get("base_url") else None
|
|
447
|
+
host = base["host"] if base else None
|
|
448
|
+
if host and host.lower() not in LOOPBACK:
|
|
449
|
+
target, a = target_of(host.lower(), base["port"]), {"address_source": "literal", "tls": base.get("tls")}
|
|
450
|
+
else:
|
|
451
|
+
target, a = prov, {"address_source": "provider", **({"address_default": target_of(host, base["port"])} if host else {})}
|
|
452
|
+
nid = node("llm", target, {**a, "provider": prov, "address_at": f"{n.file}:{c.get('line')}"}, "exact" if host else "resolved")
|
|
453
|
+
models = builder.nodes[nid].attrs.setdefault("models", [])
|
|
454
|
+
if c.get("model") and c["model"] not in models:
|
|
455
|
+
models.append(c["model"])
|
|
456
|
+
builder.add_edge(n.id, nid, "CONNECTS_TO", n.file, c.get("line"), "resolved", op=c.get("op") or "model call",
|
|
457
|
+
via=f"model {c['model']}" if c.get("model") else f"{prov} client")
|
|
458
|
+
st["connects"] += 1
|
|
459
|
+
cred(nid, [k for k in LLM_KEYS.get(prov, ()) if k in envs])
|
|
460
|
+
used_keys.update(k for k in LLM_KEYS.get(prov, ()) if k in envs)
|
|
461
|
+
|
|
462
|
+
# ---- env keys read by code, grouped by prefix
|
|
463
|
+
groups: dict = {}
|
|
464
|
+
for key in set(envs) | {k for k in ex if _split(k)}:
|
|
465
|
+
sp = _split(key)
|
|
466
|
+
if sp:
|
|
467
|
+
pre, proto, group, role = sp
|
|
468
|
+
groups.setdefault(group, {"proto": proto, "pre": pre, "keys": {}})["keys"][role] = key
|
|
469
|
+
for group, g in sorted(groups.items()):
|
|
470
|
+
ks = g["keys"]
|
|
471
|
+
addr = [ks[r] for r in ("URL", "URI", "DSN") if r in ks] or [ks[r] for r in ADDRESS[3:] if r in ks]
|
|
472
|
+
addr = [k for k in addr if k not in used_keys]
|
|
473
|
+
if not addr:
|
|
474
|
+
continue
|
|
475
|
+
akey = addr[0]
|
|
476
|
+
if f"env:{akey}" not in builder.nodes: # nothing reads it: an example file entry only
|
|
477
|
+
continue
|
|
478
|
+
proto = g["proto"]
|
|
479
|
+
if g["pre"] in ("DB", "DATABASE") and proto is None:
|
|
480
|
+
conn = val("DB_CONNECTION") or val("DATABASE_CONNECTION")
|
|
481
|
+
proto = DRIVERS.get((conn or "").lower(), SCHEMES.get((conn or "").lower()))
|
|
482
|
+
if proto is None and not conn:
|
|
483
|
+
proto = dep_sql_protocol(root, compose)
|
|
484
|
+
g["dep"] = proto is not None
|
|
485
|
+
if g["pre"] == "MAIL" and (val("MAIL_MAILER") or val("MAIL_DRIVER") or "smtp").lower() != "smtp":
|
|
486
|
+
continue
|
|
487
|
+
is_url = akey.endswith(("_URL", "_URI", "_DSN"))
|
|
488
|
+
r = resolve(proto, url_key=akey) if is_url else resolve(proto, host_key=akey, port_key=ks.get("PORT"))
|
|
489
|
+
if r is None and is_url and proto:
|
|
490
|
+
r = (f"env:{akey}", {"address_source": "env"}, "heuristic", proto)
|
|
491
|
+
if r is None:
|
|
492
|
+
continue
|
|
493
|
+
target, attrs, conf, proto = r
|
|
494
|
+
if g.get("dep"):
|
|
495
|
+
attrs = {**attrs, "protocol_source": "dependencies"}
|
|
496
|
+
nid = node(proto, target, attrs, conf)
|
|
497
|
+
builder.add_edge(nid, f"env:{akey}", "CONFIGURED_BY", None, None, conf)
|
|
498
|
+
if attrs.get("credential_in_url"):
|
|
499
|
+
builder.add_edge(nid, f"env:{akey}", "CREDENTIAL_FROM", None, None, "resolved", secret_kind="url password")
|
|
500
|
+
builder.nodes[nid].attrs.setdefault("credential_source", "env")
|
|
501
|
+
builder.nodes[nid].attrs.setdefault("credential_at", f"env:{akey}")
|
|
502
|
+
cred(nid, [ks[r] for r in ks if r in SECRET or r.endswith(tuple(SECRET))])
|
|
503
|
+
for e in readers.get(f"env:{akey}", []):
|
|
504
|
+
src = builder.nodes.get(e.src)
|
|
505
|
+
if src is None or src.kind in ("config", "env"):
|
|
506
|
+
continue
|
|
507
|
+
builder.add_edge(e.src, nid, "CONNECTS_TO", e.file, e.line, "heuristic" if conf == "heuristic" else "resolved",
|
|
508
|
+
op="connect", via=f"env {akey}")
|
|
509
|
+
st["connects"] += 1
|
|
510
|
+
return {k: v for k, v in st.items() if v} if st["systems"] else {}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _cred_kind(key: str) -> str:
|
|
514
|
+
k = key.upper()
|
|
515
|
+
return "token" if "TOKEN" in k else "key" if k.endswith("KEY") else "password"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ------------------------------------------------------------------ query: cg external / MCP external_systems
|
|
519
|
+
def external(st, pattern: str | None = None, protocol: str | None = None, source: str | None = None,
|
|
520
|
+
tls_off: bool = False, max_items: int = 200) -> dict:
|
|
521
|
+
import fnmatch
|
|
522
|
+
rows = list(st.q("SELECT id, name, attrs FROM nodes WHERE kind='external'"))
|
|
523
|
+
systems = []
|
|
524
|
+
for r in rows:
|
|
525
|
+
a = json.loads(r["attrs"] or "{}")
|
|
526
|
+
systems.append({"id": r["id"], "protocol": a.get("protocol"), "target": a.get("target"), "attrs": a})
|
|
527
|
+
# third-party HTTP origins: an adapter over http nodes (no extra nodes)
|
|
528
|
+
https: dict = {}
|
|
529
|
+
for r in st.q("SELECT id, attrs FROM nodes WHERE kind='http' AND attrs LIKE '%\"origin_kind\": \"other\"%'"):
|
|
530
|
+
a = json.loads(r["attrs"] or "{}")
|
|
531
|
+
o = a.get("origin") or ""
|
|
532
|
+
d = parse_dsn(o) if "://" in o else None
|
|
533
|
+
if not d or a.get("test_only"):
|
|
534
|
+
continue
|
|
535
|
+
kinds = {x["kind"] for x in st.q("SELECT kind FROM edges WHERE dst=?", (r["id"],))}
|
|
536
|
+
if kinds and all(k.startswith("TEST_") for k in kinds): # only tests call it (fixtures, mocks)
|
|
537
|
+
continue
|
|
538
|
+
key = target_of(d["host"], d["port"])
|
|
539
|
+
h = https.setdefault(key, {"id": f"external:https:{key}", "protocol": "https", "target": key,
|
|
540
|
+
"attrs": {"protocol": "https", "host": d["host"], "port": d["port"], "tls": d["tls"],
|
|
541
|
+
"address_source": "literal", "adapter": "http"}, "http": []})
|
|
542
|
+
h["http"].append(r["id"])
|
|
543
|
+
systems += list(https.values())
|
|
544
|
+
out = []
|
|
545
|
+
for s in sorted(systems, key=lambda x: x["id"]):
|
|
546
|
+
a = s["attrs"]
|
|
547
|
+
if protocol and s["protocol"] != protocol:
|
|
548
|
+
continue
|
|
549
|
+
if source and a.get("address_source") != source and a.get("credential_source") != source:
|
|
550
|
+
continue
|
|
551
|
+
if tls_off and a.get("tls") is not False:
|
|
552
|
+
continue
|
|
553
|
+
if pattern and not (fnmatch.fnmatchcase(s["id"], pattern) or pattern.lower() in s["id"].lower()):
|
|
554
|
+
continue
|
|
555
|
+
if s.get("http"):
|
|
556
|
+
users = [{"id": h, "via": "http", "op": "request"} for h in s["http"]]
|
|
557
|
+
ids = []
|
|
558
|
+
for h in s["http"]:
|
|
559
|
+
ids += [x["src"] for x in st.q("SELECT src FROM edges WHERE dst=? AND kind IN ('CALLS_API','SENDS_TO','CALLS')", (h,))]
|
|
560
|
+
else:
|
|
561
|
+
users = [{"id": x["src"], "op": json.loads(x["attrs"] or "{}").get("op"), "via": json.loads(x["attrs"] or "{}").get("via"),
|
|
562
|
+
"at": f"{x['file']}:{x['line']}" if x["file"] else None, "confidence": x["confidence"]}
|
|
563
|
+
for x in st.q("SELECT src, attrs, file, line, confidence FROM edges WHERE dst=? AND kind IN ('CONNECTS_TO','TEST_USES')", (s["id"],))]
|
|
564
|
+
ids = [u["id"] for u in users]
|
|
565
|
+
callers = set(ids)
|
|
566
|
+
for x in list(ids):
|
|
567
|
+
if x.startswith("connection:"):
|
|
568
|
+
callers |= {y["src"] for y in st.q("SELECT src FROM edges WHERE dst=? AND kind='USES_CONNECTION'", (x,))}
|
|
569
|
+
entries = {}
|
|
570
|
+
for c in callers:
|
|
571
|
+
for e in st.q("SELECT entry_kind, entry_count FROM node_entry WHERE node_id=?", (c,)):
|
|
572
|
+
entries[e["entry_kind"]] = entries.get(e["entry_kind"], 0) + e["entry_count"]
|
|
573
|
+
conf = [(x["dst"], x["kind"]) for x in st.q("SELECT dst, kind FROM edges WHERE src=? AND kind IN ('CONFIGURED_BY','CREDENTIAL_FROM')", (s["id"],))]
|
|
574
|
+
out.append({**s, "users": users, "callers": len(callers), "entry_kinds": entries,
|
|
575
|
+
"configured_by": sorted({k for k, v in conf if v == "CONFIGURED_BY"}),
|
|
576
|
+
"credential_from": sorted({k for k, v in conf if v == "CREDENTIAL_FROM"})})
|
|
577
|
+
summ: dict = {}
|
|
578
|
+
for s in out:
|
|
579
|
+
summ[s["protocol"]] = summ.get(s["protocol"], 0) + 1
|
|
580
|
+
return {"summary": dict(sorted(summ.items())), "systems": out[:max_items], "selected": len(out),
|
|
581
|
+
"filters": {"pattern": pattern, "protocol": protocol, "source": source, "tls_off": tls_off}}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def render_external(res: dict, max_items: int = 60) -> str:
|
|
585
|
+
if not res["systems"]:
|
|
586
|
+
return "no external systems found (see docs/external.md for what is recognised)"
|
|
587
|
+
L = [f"external systems: {res['selected']} (" + ", ".join(f"{k} {v}" for k, v in res["summary"].items()) + ")", ""]
|
|
588
|
+
for s in res["systems"][:max_items]:
|
|
589
|
+
a = s["attrs"]
|
|
590
|
+
bits = [f"address {a.get('address_source')}" + (f" @ {a['address_at']}" if a.get("address_at") else "")]
|
|
591
|
+
if a.get("address_default"):
|
|
592
|
+
bits.append(f"default {a['address_default']}")
|
|
593
|
+
if a.get("deployment_name"):
|
|
594
|
+
bits.append(f"compose service {a['deployment_name']}" + (f" ({a['image']})" if a.get("image") else ""))
|
|
595
|
+
if a.get("credential_source"):
|
|
596
|
+
bits.append(f"credentials {a['credential_source']}" + (f" @ {a['credential_at']}" if a.get("credential_at") else ""))
|
|
597
|
+
if a.get("tls") is not None:
|
|
598
|
+
bits.append("tls" if a["tls"] else "plaintext")
|
|
599
|
+
if a.get("resource"):
|
|
600
|
+
bits.append(f"resource {a['resource']}")
|
|
601
|
+
L.append(f"{s['id']} [{a.get('confidence') or 'exact'}]")
|
|
602
|
+
L.append(" " + "; ".join(bits))
|
|
603
|
+
if s["entry_kinds"]:
|
|
604
|
+
L.append(" reached from " + ", ".join(f"{k}({v})" for k, v in sorted(s["entry_kinds"].items())))
|
|
605
|
+
for u in s["users"][:8]:
|
|
606
|
+
L.append(f" <- {u['id']}" + (f" @ {u['at']}" if u.get("at") else "") + (f" ({u['via']})" if u.get("via") else ""))
|
|
607
|
+
if len(s["users"]) > 8:
|
|
608
|
+
L.append(f" ... {len(s['users']) - 8} more users")
|
|
609
|
+
if s["configured_by"]:
|
|
610
|
+
L.append(" configured by " + ", ".join(s["configured_by"][:6]))
|
|
611
|
+
if res["selected"] > max_items:
|
|
612
|
+
L.append(f"... {res['selected'] - max_items} more")
|
|
613
|
+
return "\n".join(L)
|