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.
Files changed (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +859 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. 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
+ }