codegraph-engine 3.0.0__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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""Secret detection, connection-string sanitization, SQL parameter redaction, and SQLite security auditing.
|
|
2
|
+
|
|
3
|
+
Core Principle:
|
|
4
|
+
CodeGraph may understand that a secret exists (e.g. `READS_ENV DATABASE_URL`,
|
|
5
|
+
`provider=postgresql`, `table=products`).
|
|
6
|
+
CodeGraph must NEVER reveal, index, persist, or return the secret value itself.
|
|
7
|
+
|
|
8
|
+
Boundaries protected by this module:
|
|
9
|
+
1. Indexing & FTS chunk persistence
|
|
10
|
+
2. Text search (`search_code`)
|
|
11
|
+
3. File inspection (`get_file`, `read_file`)
|
|
12
|
+
4. Context compilation (`get_context`)
|
|
13
|
+
5. Database discovery, schema extraction & migration analysis
|
|
14
|
+
6. Runtime trace / event / SQL log ingestion & querying
|
|
15
|
+
7. MCP responses, diagnostics, logs & error messages
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
import math
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
from typing import Any, cast
|
|
24
|
+
|
|
25
|
+
REDACTED_TOKEN = "[REDACTED]"
|
|
26
|
+
REDACTED_SECRET = "[REDACTED_SECRET]"
|
|
27
|
+
REDACTED_PRIVATE_KEY = "[REDACTED_PRIVATE_KEY]"
|
|
28
|
+
REDACTED_DB_CREDENTIALS = "DATABASE_CREDENTIALS: REDACTED"
|
|
29
|
+
|
|
30
|
+
# Environment variable / config key names that hold secrets
|
|
31
|
+
SECRET_ENV_EXACT_NAMES: frozenset[str] = frozenset({
|
|
32
|
+
"DATABASE_URL",
|
|
33
|
+
"DATABASE_URI",
|
|
34
|
+
"SQLALCHEMY_DATABASE_URI",
|
|
35
|
+
"TEST_DATABASE_URL",
|
|
36
|
+
"ASYNC_DATABASE_URL",
|
|
37
|
+
"DIRECT_URL",
|
|
38
|
+
"SHADOW_DATABASE_URL",
|
|
39
|
+
"REDIS_URL",
|
|
40
|
+
"REDIS_URI",
|
|
41
|
+
"MONGO_URI",
|
|
42
|
+
"MONGODB_URI",
|
|
43
|
+
"DB_PASSWORD",
|
|
44
|
+
"DB_PASS",
|
|
45
|
+
"DB_SECRET",
|
|
46
|
+
"DB_USER",
|
|
47
|
+
"DB_USERNAME",
|
|
48
|
+
"POSTGRES_PASSWORD",
|
|
49
|
+
"MYSQL_PASSWORD",
|
|
50
|
+
"MYSQL_ROOT_PASSWORD",
|
|
51
|
+
"OPENAI_API_KEY",
|
|
52
|
+
"GEMINI_API_KEY",
|
|
53
|
+
"GOOGLE_API_KEY",
|
|
54
|
+
"ANTHROPIC_API_KEY",
|
|
55
|
+
"HF_TOKEN",
|
|
56
|
+
"HUGGINGFACE_TOKEN",
|
|
57
|
+
"GITHUB_TOKEN",
|
|
58
|
+
"GH_TOKEN",
|
|
59
|
+
"GITLAB_TOKEN",
|
|
60
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
61
|
+
"AWS_ACCESS_KEY_ID",
|
|
62
|
+
"AWS_SESSION_TOKEN",
|
|
63
|
+
"AZURE_CLIENT_SECRET",
|
|
64
|
+
"GCP_SERVICE_ACCOUNT_KEY",
|
|
65
|
+
"STRIPE_SECRET_KEY",
|
|
66
|
+
"STRIPE_API_KEY",
|
|
67
|
+
"STRIPE_WEBHOOK_SECRET",
|
|
68
|
+
"SMTP_PASSWORD",
|
|
69
|
+
"SENDGRID_API_KEY",
|
|
70
|
+
"TWILIO_AUTH_TOKEN",
|
|
71
|
+
"JWT",
|
|
72
|
+
"JWT_SECRET",
|
|
73
|
+
"JWT_SECRET_KEY",
|
|
74
|
+
"SECRET_KEY",
|
|
75
|
+
"APP_SECRET",
|
|
76
|
+
"API_KEY",
|
|
77
|
+
"ACCESS_TOKEN",
|
|
78
|
+
"REFRESH_TOKEN",
|
|
79
|
+
"BEARER_TOKEN",
|
|
80
|
+
"CLIENT_SECRET",
|
|
81
|
+
"OAUTH_CLIENT_SECRET",
|
|
82
|
+
"WEBHOOK_SECRET",
|
|
83
|
+
"ENCRYPTION_KEY",
|
|
84
|
+
"SIGNING_KEY",
|
|
85
|
+
"PRIVATE_KEY",
|
|
86
|
+
"SSH_PRIVATE_KEY",
|
|
87
|
+
"COOKIE_SECRET",
|
|
88
|
+
"SESSION_SECRET",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
SECRET_ENV_SUFFIXES: tuple[str, ...] = (
|
|
92
|
+
"_PASSWORD",
|
|
93
|
+
"_PASS",
|
|
94
|
+
"_PASSWD",
|
|
95
|
+
"_SECRET",
|
|
96
|
+
"_SECRET_KEY",
|
|
97
|
+
"_TOKEN",
|
|
98
|
+
"_API_KEY",
|
|
99
|
+
"_APIKEY",
|
|
100
|
+
"_PRIVATE_KEY",
|
|
101
|
+
"_ACCESS_KEY",
|
|
102
|
+
"_CLIENT_SECRET",
|
|
103
|
+
"_WEBHOOK_SECRET",
|
|
104
|
+
"_SIGNING_KEY",
|
|
105
|
+
"_ENCRYPTION_KEY",
|
|
106
|
+
"_AUTH_TOKEN",
|
|
107
|
+
"_CREDENTIALS",
|
|
108
|
+
"_CONN_STR",
|
|
109
|
+
"_CONNECTION_STRING",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Sensitive HTTP / Runtime telemetry keys that must never be persisted
|
|
113
|
+
SENSITIVE_RUNTIME_KEYS: frozenset[str] = frozenset({
|
|
114
|
+
"authorization",
|
|
115
|
+
"proxy-authorization",
|
|
116
|
+
"cookie",
|
|
117
|
+
"set-cookie",
|
|
118
|
+
"x-api-key",
|
|
119
|
+
"x-auth-token",
|
|
120
|
+
"x-csrf-token",
|
|
121
|
+
"x-access-token",
|
|
122
|
+
"password",
|
|
123
|
+
"passwd",
|
|
124
|
+
"secret",
|
|
125
|
+
"token",
|
|
126
|
+
"access_token",
|
|
127
|
+
"refresh_token",
|
|
128
|
+
"id_token",
|
|
129
|
+
"api_key",
|
|
130
|
+
"apikey",
|
|
131
|
+
"client_secret",
|
|
132
|
+
"private_key",
|
|
133
|
+
"request_body",
|
|
134
|
+
"response_body",
|
|
135
|
+
"raw_body",
|
|
136
|
+
"body",
|
|
137
|
+
"payload",
|
|
138
|
+
"form_data",
|
|
139
|
+
"credentials",
|
|
140
|
+
"connection_string",
|
|
141
|
+
"db.connection_string",
|
|
142
|
+
"db.password",
|
|
143
|
+
"db.user",
|
|
144
|
+
"http.request.header.authorization",
|
|
145
|
+
"http.request.header.cookie",
|
|
146
|
+
"http.response.header.set_cookie",
|
|
147
|
+
"http.request.body",
|
|
148
|
+
"http.response.body",
|
|
149
|
+
"session_id",
|
|
150
|
+
"sessionid",
|
|
151
|
+
"jwt",
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
# 1. PEM / SSH / PGP Private Key block pattern (multiline)
|
|
155
|
+
_PRIVATE_KEY_BLOCK_RE = re.compile(
|
|
156
|
+
r"-----BEGIN [A-Z0-9 _-]*PRIVATE KEY(?:\s+BLOCK)?-----"
|
|
157
|
+
r"[\s\S]*?"
|
|
158
|
+
r"-----END [A-Z0-9 _-]*PRIVATE KEY(?:\s+BLOCK)?-----",
|
|
159
|
+
re.MULTILINE,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# 2. Database URLs with credentials:
|
|
163
|
+
# e.g. postgresql://admin:FakePassword123@localhost:5432/shop
|
|
164
|
+
# e.g. mysql+pymysql://user:pass@host/db
|
|
165
|
+
_DB_CONN_URL_RE = re.compile(
|
|
166
|
+
r"\b(?P<scheme>(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis(?:s)?|mssql|oracle|cockroachdb|clickhouse|amqp(?:s)?)"
|
|
167
|
+
r"(?:\+[a-z0-9_]+)?)://"
|
|
168
|
+
r"(?P<userinfo>[^/\s@\"'`]+@)"
|
|
169
|
+
r"(?P<hostpath>[^\s\"'`,;)]+)",
|
|
170
|
+
re.IGNORECASE,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# 3. Well-known API key / token formats
|
|
174
|
+
_KNOWN_TOKEN_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
175
|
+
# OpenAI / Stripe / Anthropic style sk-... keys (including sk-test-..., sk-live-..., sk-proj-...)
|
|
176
|
+
(re.compile(r"\bsk-(?:test-|live-|proj-|ant-)?[A-Za-z0-9_-]{4,}\b"), REDACTED_SECRET),
|
|
177
|
+
(re.compile(r"\brk_(?:test|live)_[A-Za-z0-9]{4,}\b"), REDACTED_SECRET),
|
|
178
|
+
# Google / Gemini API keys (AIza...)
|
|
179
|
+
(re.compile(r"\bAIza[0-9A-Za-z_-]{16,}\b"), REDACTED_SECRET),
|
|
180
|
+
# AWS Access Key IDs
|
|
181
|
+
(re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), REDACTED_SECRET),
|
|
182
|
+
# GitHub tokens
|
|
183
|
+
(re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}\b"), REDACTED_SECRET),
|
|
184
|
+
(re.compile(r"\bgithub_pat_[A-Za-z0-9_]{16,}\b"), REDACTED_SECRET),
|
|
185
|
+
# GitLab tokens
|
|
186
|
+
(re.compile(r"\bglpat-[A-Za-z0-9_-]{10,}\b"), REDACTED_SECRET),
|
|
187
|
+
# Hugging Face tokens
|
|
188
|
+
(re.compile(r"\bhf_[A-Za-z0-9]{10,}\b"), REDACTED_SECRET),
|
|
189
|
+
# Slack tokens
|
|
190
|
+
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), REDACTED_SECRET),
|
|
191
|
+
# Standard JWT (3 dot-separated base64url segments starting with eyJ)
|
|
192
|
+
(re.compile(r"\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b"), REDACTED_SECRET),
|
|
193
|
+
# Authorization Bearer / Basic header values
|
|
194
|
+
(
|
|
195
|
+
re.compile(
|
|
196
|
+
r"(?i)\b(Authorization\s*[:=]\s*[\"']?(?:Bearer|Basic|Token)\s+)([^\s\"'`,;]+)"
|
|
197
|
+
),
|
|
198
|
+
r"\1[REDACTED]",
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# 4. Secret environment / config assignment pattern:
|
|
203
|
+
# Matches KEY = "value", KEY='value', KEY=value, "KEY": "value"
|
|
204
|
+
_SECRET_ASSIGNMENT_RE = re.compile(
|
|
205
|
+
r"(?P<prefix>\b(?P<key>[A-Za-z_][A-Za-z0-9_]*)\b"
|
|
206
|
+
r"(?:\s*\"?\s*[:=]\s*))"
|
|
207
|
+
r"(?P<quote>[\"']?)"
|
|
208
|
+
r"(?P<val>[^\s\"'#,;}\]\n]+)"
|
|
209
|
+
r"(?P=quote)"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def is_secret_variable_name(name: str) -> bool:
|
|
214
|
+
"""Return True if an identifier or environment variable name denotes a secret or credential."""
|
|
215
|
+
if not name:
|
|
216
|
+
return False
|
|
217
|
+
upper = name.strip().upper()
|
|
218
|
+
if upper in SECRET_ENV_EXACT_NAMES:
|
|
219
|
+
return True
|
|
220
|
+
if any(upper.endswith(suffix) for suffix in SECRET_ENV_SUFFIXES):
|
|
221
|
+
return True
|
|
222
|
+
if "PASSWORD" in upper or "PRIVATE_KEY" in upper or "SECRET_KEY" in upper or "API_KEY" in upper:
|
|
223
|
+
return True
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _shannon_entropy(value: str) -> float:
|
|
228
|
+
if not value:
|
|
229
|
+
return 0.0
|
|
230
|
+
length = len(value)
|
|
231
|
+
counts: dict[str, int] = {}
|
|
232
|
+
for ch in value:
|
|
233
|
+
counts[ch] = counts.get(ch, 0) + 1
|
|
234
|
+
return -sum((c / length) * math.log2(c / length) for c in counts.values())
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def is_probable_secret_value(value: str, key_hint: str = "") -> bool:
|
|
238
|
+
"""Conservative secret value detector combining key-name rules, known prefixes, and entropy."""
|
|
239
|
+
cleaned = value.strip().strip("\"'")
|
|
240
|
+
if not cleaned or cleaned in (
|
|
241
|
+
REDACTED_TOKEN,
|
|
242
|
+
REDACTED_SECRET,
|
|
243
|
+
REDACTED_PRIVATE_KEY,
|
|
244
|
+
"REDACTED",
|
|
245
|
+
"***",
|
|
246
|
+
"None",
|
|
247
|
+
"null",
|
|
248
|
+
"true",
|
|
249
|
+
"false",
|
|
250
|
+
"True",
|
|
251
|
+
"False",
|
|
252
|
+
"localhost",
|
|
253
|
+
"127.0.0.1",
|
|
254
|
+
"postgres",
|
|
255
|
+
"postgresql",
|
|
256
|
+
"sqlite",
|
|
257
|
+
"mysql",
|
|
258
|
+
):
|
|
259
|
+
return False
|
|
260
|
+
# Ignore code expressions such as os.getenv(...), os.environ.get(...), settings.X
|
|
261
|
+
if cleaned.startswith(("os.", "settings.", "config.", "env.", "${", "$(")):
|
|
262
|
+
return False
|
|
263
|
+
if is_secret_variable_name(key_hint):
|
|
264
|
+
return True
|
|
265
|
+
if _PRIVATE_KEY_BLOCK_RE.search(cleaned) or _DB_CONN_URL_RE.search(cleaned):
|
|
266
|
+
return True
|
|
267
|
+
for pat, _ in _KNOWN_TOKEN_PATTERNS:
|
|
268
|
+
if pat.search(cleaned):
|
|
269
|
+
return True
|
|
270
|
+
# High-entropy long token check
|
|
271
|
+
if len(cleaned) >= 24 and _shannon_entropy(cleaned) >= 4.2 and not ("/" in cleaned or "." in cleaned):
|
|
272
|
+
return True
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def redact_connection_string(url: str) -> str:
|
|
277
|
+
"""Redact credentials in a database connection string while preserving structural host/db metadata.
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
postgresql://admin:FakePassword123@localhost:5432/shop
|
|
281
|
+
-> postgresql://***:***@localhost:5432/shop
|
|
282
|
+
"""
|
|
283
|
+
if not url:
|
|
284
|
+
return url
|
|
285
|
+
|
|
286
|
+
def _repl(match: re.Match[str]) -> str:
|
|
287
|
+
scheme = match.group("scheme")
|
|
288
|
+
hostpath = match.group("hostpath")
|
|
289
|
+
return f"{scheme}://***:***@{hostpath}"
|
|
290
|
+
|
|
291
|
+
redacted = _DB_CONN_URL_RE.sub(_repl, url) if "://" in url else url
|
|
292
|
+
# Also redact query-string password/api_key parameters if present
|
|
293
|
+
if "=" in redacted and ("?" in redacted or "&" in redacted):
|
|
294
|
+
redacted = _CONN_QUERY_PARAM_RE.sub(r"\1[REDACTED]", redacted)
|
|
295
|
+
return redacted
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
_CONN_QUERY_PARAM_RE = re.compile(
|
|
299
|
+
r"(?i)([?&](?:password|passwd|pwd|secret|token|api_key|access_token)=)([^&\s\"']+)"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
_KNOWN_TOKEN_TRIGGER_RE = re.compile(
|
|
303
|
+
r"\b(?:sk-|rk_|AIza|AKIA|ASIA|gh[pousr]_|github_pat_|glpat-|hf_|xox[baprs]-|eyJ)|(?i:\bAuthorization\s*[:=])"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def parse_safe_connection_metadata(url: str) -> dict[str, str]:
|
|
308
|
+
"""Extract only safe structural database metadata from a connection URL, redacting credentials."""
|
|
309
|
+
match = _DB_CONN_URL_RE.search(url or "")
|
|
310
|
+
if not match:
|
|
311
|
+
if "sqlite" in (url or "").lower():
|
|
312
|
+
return {
|
|
313
|
+
"provider": "sqlite",
|
|
314
|
+
"dialect": "sqlite",
|
|
315
|
+
"host": "local",
|
|
316
|
+
"port": "",
|
|
317
|
+
"database": "sqlite",
|
|
318
|
+
"database_name": "sqlite",
|
|
319
|
+
"credentials": "REDACTED",
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
"provider": "UNKNOWN",
|
|
323
|
+
"dialect": "UNKNOWN",
|
|
324
|
+
"host": "",
|
|
325
|
+
"port": "",
|
|
326
|
+
"database": "UNKNOWN",
|
|
327
|
+
"database_name": "UNKNOWN",
|
|
328
|
+
"credentials": "REDACTED",
|
|
329
|
+
}
|
|
330
|
+
scheme = match.group("scheme").lower().split("+")[0]
|
|
331
|
+
if scheme == "postgres":
|
|
332
|
+
scheme = "postgresql"
|
|
333
|
+
dialect = "postgres" if scheme in ("postgres", "postgresql") else scheme
|
|
334
|
+
hostpath = match.group("hostpath")
|
|
335
|
+
host_port, _, db_name = hostpath.partition("/")
|
|
336
|
+
db_name = db_name.split("?")[0] or "UNKNOWN"
|
|
337
|
+
host, _, port = host_port.partition(":")
|
|
338
|
+
return {
|
|
339
|
+
"provider": scheme,
|
|
340
|
+
"dialect": dialect,
|
|
341
|
+
"host": host or "localhost",
|
|
342
|
+
"port": port or "",
|
|
343
|
+
"database": db_name,
|
|
344
|
+
"database_name": db_name,
|
|
345
|
+
"credentials": "REDACTED",
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def redact_secrets(text: str) -> str:
|
|
350
|
+
"""Deterministically redact private keys, DB credentials, API keys, JWTs, and secret assignments."""
|
|
351
|
+
if not text:
|
|
352
|
+
return text
|
|
353
|
+
|
|
354
|
+
# 1. Private key blocks
|
|
355
|
+
out = _PRIVATE_KEY_BLOCK_RE.sub(REDACTED_PRIVATE_KEY, text) if "PRIVATE KEY" in text else text
|
|
356
|
+
|
|
357
|
+
# 2. Database connection URLs with embedded user:password@
|
|
358
|
+
if "://" in out or ("=" in out and ("?" in out or "&" in out)):
|
|
359
|
+
out = redact_connection_string(out)
|
|
360
|
+
|
|
361
|
+
# 3. Known API key and token formats
|
|
362
|
+
if _KNOWN_TOKEN_TRIGGER_RE.search(out):
|
|
363
|
+
for pat, replacement in _KNOWN_TOKEN_PATTERNS:
|
|
364
|
+
out = pat.sub(replacement, out)
|
|
365
|
+
|
|
366
|
+
# 4. Secret variable assignments (e.g. OPENAI_API_KEY=..., GEMINI_API_KEY=..., JWT=..., AWS_SECRET_ACCESS_KEY=...)
|
|
367
|
+
if "=" in out or ":" in out:
|
|
368
|
+
def _assign_repl(match: re.Match[str]) -> str:
|
|
369
|
+
key = match.group("key")
|
|
370
|
+
prefix = match.group("prefix")
|
|
371
|
+
quote = match.group("quote")
|
|
372
|
+
val = match.group("val")
|
|
373
|
+
if not is_secret_variable_name(key):
|
|
374
|
+
return match.group(0)
|
|
375
|
+
# Do not redact safe code references like os.environ / os.getenv / None
|
|
376
|
+
if val.startswith(("os.", "settings.", "config.", "env.", "None", "null", "[REDACTED")):
|
|
377
|
+
return match.group(0)
|
|
378
|
+
# If the value is a DB connection URL that was already user/pass redacted, still redact if assigned to DATABASE_URL
|
|
379
|
+
if key.upper() in ("DATABASE_URL", "DATABASE_URI", "SQLALCHEMY_DATABASE_URI", "REDIS_URL", "MONGO_URI"):
|
|
380
|
+
if "://***:***@" in val:
|
|
381
|
+
return f"{prefix}{quote}{val}{quote}"
|
|
382
|
+
return f"{prefix}{quote}{REDACTED_TOKEN}{quote}"
|
|
383
|
+
return f"{prefix}{quote}{REDACTED_TOKEN}{quote}"
|
|
384
|
+
|
|
385
|
+
out = _SECRET_ASSIGNMENT_RE.sub(_assign_repl, out)
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def contains_private_key(text: str) -> bool:
|
|
390
|
+
"""Return True if text contains a PEM, RSA, EC, OpenSSH, or PGP private key block."""
|
|
391
|
+
if not text or "PRIVATE KEY" not in text:
|
|
392
|
+
return False
|
|
393
|
+
return bool(_PRIVATE_KEY_BLOCK_RE.search(text))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def normalize_and_redact_sql(sql: str) -> str:
|
|
397
|
+
"""Normalize SQL queries and redact all literal bound parameter values.
|
|
398
|
+
|
|
399
|
+
Example:
|
|
400
|
+
INSERT INTO users(email, password) VALUES ('user@example.com', 'secret123')
|
|
401
|
+
-> INSERT INTO users(email, password) VALUES (?, ?)
|
|
402
|
+
"""
|
|
403
|
+
if not sql:
|
|
404
|
+
return ""
|
|
405
|
+
cleaned = redact_secrets(sql.strip())
|
|
406
|
+
# Replace single-quoted SQL string literals with '?'
|
|
407
|
+
cleaned = re.sub(r"'(?:''|[^'])*'", "?", cleaned)
|
|
408
|
+
# Replace double-quoted literals inside VALUES (...) or = "..." with '?'
|
|
409
|
+
cleaned = re.sub(r"(?i)(\bVALUES\s*\([^)]*)", lambda m: re.sub(r'"[^"]*"', "?", m.group(1)), cleaned)
|
|
410
|
+
# Replace numeric literals inside VALUES (...) or after = / IN
|
|
411
|
+
cleaned = re.sub(r"(?i)(=\s*)\b\d+(?:\.\d+)?\b", r"\1?", cleaned)
|
|
412
|
+
cleaned = re.sub(
|
|
413
|
+
r"(?i)(\bVALUES\s*\()([^)]+)(\))",
|
|
414
|
+
lambda m: m.group(1) + ", ".join("?" for _ in m.group(2).split(",")) + m.group(3),
|
|
415
|
+
cleaned,
|
|
416
|
+
)
|
|
417
|
+
# Collapse excessive whitespace
|
|
418
|
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
419
|
+
return cleaned
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def redact_payload[T](obj: T) -> T:
|
|
423
|
+
"""Recursively redact secrets, credentials, headers, cookies, and raw bodies from dicts/lists/strings."""
|
|
424
|
+
if obj is None:
|
|
425
|
+
return obj
|
|
426
|
+
if isinstance(obj, str):
|
|
427
|
+
return cast(T, redact_secrets(obj))
|
|
428
|
+
if isinstance(obj, (int, float, bool)):
|
|
429
|
+
return obj
|
|
430
|
+
if isinstance(obj, list):
|
|
431
|
+
return cast(T, [redact_payload(item) for item in obj])
|
|
432
|
+
if isinstance(obj, tuple):
|
|
433
|
+
return cast(T, tuple(redact_payload(item) for item in obj))
|
|
434
|
+
if isinstance(obj, dict):
|
|
435
|
+
sanitized: dict[str, Any] = {}
|
|
436
|
+
for k, v in obj.items():
|
|
437
|
+
key_str = str(k)
|
|
438
|
+
key_lower = key_str.lower().strip()
|
|
439
|
+
if key_lower in SENSITIVE_RUNTIME_KEYS or is_secret_variable_name(key_str):
|
|
440
|
+
sanitized[key_str] = REDACTED_TOKEN
|
|
441
|
+
elif key_lower in ("sql", "statement", "db.statement", "query_text", "normalized_sql") and isinstance(v, str):
|
|
442
|
+
sanitized[key_str] = normalize_and_redact_sql(v)
|
|
443
|
+
else:
|
|
444
|
+
sanitized[key_str] = redact_payload(v)
|
|
445
|
+
return cast(T, sanitized)
|
|
446
|
+
return obj
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def detect_env_variable_reads(
|
|
450
|
+
content: str,
|
|
451
|
+
file_path: str,
|
|
452
|
+
module_name: str = "",
|
|
453
|
+
tree: ast.AST | None = None,
|
|
454
|
+
) -> list[dict[str, Any]]:
|
|
455
|
+
"""Detect environment variable reads (`os.getenv`, `os.environ.get`, `os.environ[...]`)
|
|
456
|
+
without ever recording the secret value itself.
|
|
457
|
+
"""
|
|
458
|
+
results: list[dict[str, Any]] = []
|
|
459
|
+
if not content:
|
|
460
|
+
return results
|
|
461
|
+
|
|
462
|
+
if file_path.endswith(".py"):
|
|
463
|
+
if "getenv" not in content and "environ" not in content:
|
|
464
|
+
return results
|
|
465
|
+
if tree is None:
|
|
466
|
+
try:
|
|
467
|
+
tree = ast.parse(content)
|
|
468
|
+
except SyntaxError:
|
|
469
|
+
tree = None
|
|
470
|
+
|
|
471
|
+
if tree is not None:
|
|
472
|
+
current_scope: list[str] = []
|
|
473
|
+
|
|
474
|
+
def _visit(node: ast.AST, scope_stack: list[str]) -> None:
|
|
475
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
476
|
+
next_scope = [*scope_stack, node.name]
|
|
477
|
+
for child in ast.iter_child_nodes(node):
|
|
478
|
+
_visit(child, next_scope)
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
# Match os.getenv("DATABASE_URL") or os.environ.get("DATABASE_URL")
|
|
482
|
+
if isinstance(node, ast.Call):
|
|
483
|
+
func_name = ""
|
|
484
|
+
if isinstance(node.func, ast.Attribute):
|
|
485
|
+
if isinstance(node.func.value, ast.Name) and node.func.value.id in ("os", "environ"):
|
|
486
|
+
func_name = f"{node.func.value.id}.{node.func.attr}"
|
|
487
|
+
elif (
|
|
488
|
+
isinstance(node.func.value, ast.Attribute)
|
|
489
|
+
and isinstance(node.func.value.value, ast.Name)
|
|
490
|
+
and node.func.value.value.id == "os"
|
|
491
|
+
and node.func.value.attr == "environ"
|
|
492
|
+
):
|
|
493
|
+
func_name = f"os.environ.{node.func.attr}"
|
|
494
|
+
if func_name in ("os.getenv", "environ.get", "os.environ.get") and node.args:
|
|
495
|
+
first_arg = node.args[0]
|
|
496
|
+
if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
|
|
497
|
+
env_name = first_arg.value.strip()
|
|
498
|
+
if env_name:
|
|
499
|
+
src_id = (
|
|
500
|
+
f"{module_name}.{'.'.join(scope_stack)}"
|
|
501
|
+
if module_name and scope_stack
|
|
502
|
+
else (module_name or file_path)
|
|
503
|
+
)
|
|
504
|
+
results.append({
|
|
505
|
+
"source": src_id,
|
|
506
|
+
"target": f"env.{env_name}",
|
|
507
|
+
"env_name": env_name,
|
|
508
|
+
"is_secret": is_secret_variable_name(env_name),
|
|
509
|
+
"relationship": "READS_ENV",
|
|
510
|
+
"file": file_path,
|
|
511
|
+
"line": getattr(node, "lineno", 1),
|
|
512
|
+
"evidence": f"{func_name}({env_name!r})",
|
|
513
|
+
"confidence": "HIGH",
|
|
514
|
+
"evidence_class": "AST_VERIFIED",
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
# Match os.environ["DATABASE_URL"]
|
|
518
|
+
if isinstance(node, ast.Subscript):
|
|
519
|
+
is_os_environ = (
|
|
520
|
+
isinstance(node.value, ast.Attribute)
|
|
521
|
+
and isinstance(node.value.value, ast.Name)
|
|
522
|
+
and node.value.value.id == "os"
|
|
523
|
+
and node.value.attr == "environ"
|
|
524
|
+
) or (isinstance(node.value, ast.Name) and node.value.id == "environ")
|
|
525
|
+
if is_os_environ and isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str):
|
|
526
|
+
env_name = node.slice.value.strip()
|
|
527
|
+
if env_name:
|
|
528
|
+
src_id = (
|
|
529
|
+
f"{module_name}.{'.'.join(scope_stack)}"
|
|
530
|
+
if module_name and scope_stack
|
|
531
|
+
else (module_name or file_path)
|
|
532
|
+
)
|
|
533
|
+
results.append({
|
|
534
|
+
"source": src_id,
|
|
535
|
+
"target": f"env.{env_name}",
|
|
536
|
+
"env_name": env_name,
|
|
537
|
+
"is_secret": is_secret_variable_name(env_name),
|
|
538
|
+
"relationship": "READS_ENV",
|
|
539
|
+
"file": file_path,
|
|
540
|
+
"line": getattr(node, "lineno", 1),
|
|
541
|
+
"evidence": f"os.environ[{env_name!r}]",
|
|
542
|
+
"confidence": "HIGH",
|
|
543
|
+
"evidence_class": "AST_VERIFIED",
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
for child in ast.iter_child_nodes(node):
|
|
547
|
+
_visit(child, scope_stack)
|
|
548
|
+
|
|
549
|
+
_visit(tree, current_scope)
|
|
550
|
+
|
|
551
|
+
# Also detect process.env.VAR_NAME in JS/TS or module-level secret variable names
|
|
552
|
+
for line_no, raw_line in enumerate(content.splitlines(), start=1):
|
|
553
|
+
for m in re.finditer(r"\bprocess\.env\.([A-Z_][A-Z0-9_]*)\b", raw_line):
|
|
554
|
+
env_name = m.group(1)
|
|
555
|
+
results.append({
|
|
556
|
+
"source": module_name or file_path,
|
|
557
|
+
"target": f"env.{env_name}",
|
|
558
|
+
"env_name": env_name,
|
|
559
|
+
"is_secret": is_secret_variable_name(env_name),
|
|
560
|
+
"relationship": "READS_ENV",
|
|
561
|
+
"file": file_path,
|
|
562
|
+
"line": line_no,
|
|
563
|
+
"evidence": f"process.env.{env_name}",
|
|
564
|
+
"confidence": "HIGH",
|
|
565
|
+
"evidence_class": "AST_VERIFIED",
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
return results
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def audit_sqlite_for_secrets(
|
|
572
|
+
con: sqlite3.Connection,
|
|
573
|
+
seeded_secrets: list[str] | tuple[str, ...] = (),
|
|
574
|
+
) -> dict[str, Any]:
|
|
575
|
+
"""Scan all SQLite tables (including FTS, graph_edges, chunks, database, and runtime tables)
|
|
576
|
+
to verify that zero raw secret values or seeded test secrets were persisted.
|
|
577
|
+
"""
|
|
578
|
+
violations: list[dict[str, Any]] = []
|
|
579
|
+
tables = [
|
|
580
|
+
str(r[0])
|
|
581
|
+
for r in con.execute(
|
|
582
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
583
|
+
).fetchall()
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
for tbl in tables:
|
|
587
|
+
# Skip internal FTS shadow tables except the main content view
|
|
588
|
+
if tbl.startswith("chunks_fts_") and tbl != "chunks_fts":
|
|
589
|
+
continue
|
|
590
|
+
try:
|
|
591
|
+
rows = con.execute(f'SELECT * FROM "{tbl}"').fetchall()
|
|
592
|
+
except sqlite3.OperationalError:
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
for row_idx, row in enumerate(rows):
|
|
596
|
+
if hasattr(row, "keys"):
|
|
597
|
+
row_vals = [str(row[k]) for k in row.keys() if row[k] is not None]
|
|
598
|
+
else:
|
|
599
|
+
row_vals = [str(v) for v in row if v is not None]
|
|
600
|
+
joined = " | ".join(row_vals)
|
|
601
|
+
|
|
602
|
+
# Check explicit seeded secrets
|
|
603
|
+
for secret in seeded_secrets:
|
|
604
|
+
if secret and secret in joined:
|
|
605
|
+
violations.append({
|
|
606
|
+
"table": tbl,
|
|
607
|
+
"row_index": row_idx,
|
|
608
|
+
"reason": f"Seeded secret found in table '{tbl}'",
|
|
609
|
+
"matched": secret[:6] + "***",
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
# Check private key blocks or unredacted DB URLs
|
|
613
|
+
if _PRIVATE_KEY_BLOCK_RE.search(joined):
|
|
614
|
+
violations.append({
|
|
615
|
+
"table": tbl,
|
|
616
|
+
"row_index": row_idx,
|
|
617
|
+
"reason": f"Unredacted private key block in table '{tbl}'",
|
|
618
|
+
})
|
|
619
|
+
for m in _DB_CONN_URL_RE.finditer(joined):
|
|
620
|
+
if "***:***" not in m.group(0) and "[REDACTED]" not in m.group(0) and "<REDACTED>" not in m.group(0):
|
|
621
|
+
violations.append({
|
|
622
|
+
"table": tbl,
|
|
623
|
+
"row_index": row_idx,
|
|
624
|
+
"reason": f"Unredacted database connection string in table '{tbl}'",
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
"clean": len(violations) == 0,
|
|
629
|
+
"tables_scanned": len(tables),
|
|
630
|
+
"violation_count": len(violations),
|
|
631
|
+
"violations": violations,
|
|
632
|
+
"findings": violations,
|
|
633
|
+
}
|