aizee 6.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. aizee-6.8.0.dist-info/METADATA +617 -0
  2. aizee-6.8.0.dist-info/RECORD +322 -0
  3. aizee-6.8.0.dist-info/WHEEL +5 -0
  4. aizee-6.8.0.dist-info/entry_points.txt +2 -0
  5. aizee-6.8.0.dist-info/licenses/LICENSE +21 -0
  6. aizee-6.8.0.dist-info/licenses/NOTICE +33 -0
  7. aizee-6.8.0.dist-info/top_level.txt +7 -0
  8. aizee_cli.py +2907 -0
  9. aizee_mcp/API.md +353 -0
  10. aizee_mcp/__init__.py +3 -0
  11. aizee_mcp/_compat.py +50 -0
  12. aizee_mcp/adapters.py +555 -0
  13. aizee_mcp/agent.py +208 -0
  14. aizee_mcp/aizee_server.py +501 -0
  15. aizee_mcp/config.json +29 -0
  16. aizee_mcp/rbac.py +109 -0
  17. aizee_mcp/rbac.yaml +25 -0
  18. aizee_mcp/schema_profile.py +74 -0
  19. aizee_mcp/tools/__init__.py +94 -0
  20. aizee_mcp/tools/ads_tools.py +285 -0
  21. aizee_mcp/tools/analytics_tools.py +408 -0
  22. aizee_mcp/tools/common.py +103 -0
  23. aizee_mcp/tools/context_tools.py +140 -0
  24. aizee_mcp/tools/cro_tools.py +295 -0
  25. aizee_mcp/tools/doctor_tools.py +69 -0
  26. aizee_mcp/tools/email_tools.py +555 -0
  27. aizee_mcp/tools/freelance_tools.py +300 -0
  28. aizee_mcp/tools/intel_tools.py +114 -0
  29. aizee_mcp/tools/memory_tools.py +236 -0
  30. aizee_mcp/tools/policy_tools.py +135 -0
  31. aizee_mcp/tools/schemas.py +296 -0
  32. aizee_mcp/tools/seo_page_reporters.py +181 -0
  33. aizee_mcp/tools/seo_tools.py +1047 -0
  34. aizee_mcp/tools/social_tools.py +358 -0
  35. aizee_mcp/tools/task_tools.py +135 -0
  36. aizee_mcp/tools/workflow_tools.py +209 -0
  37. config.py +64 -0
  38. eval/__init__.py +0 -0
  39. eval/agent_benchmark.py +268 -0
  40. eval/chaos.py +296 -0
  41. eval/harness.py +234 -0
  42. eval/pipeline.py +460 -0
  43. eval/prompt_injection_suite.py +385 -0
  44. eval/redteam.py +284 -0
  45. eval/release_gate.py +157 -0
  46. eval/reliability.py +510 -0
  47. eval/rubric.py +263 -0
  48. eval/scenarios/goldfish-review.yaml +61 -0
  49. eval/scenarios/persona.yaml +22 -0
  50. eval/scenarios/security.yaml +43 -0
  51. eval/skill_scenarios.py +235 -0
  52. eval/stages.py +135 -0
  53. eval/trigger_cases.json +118 -0
  54. eval/vibe.py +234 -0
  55. memory/__init__.py +22 -0
  56. memory/archive/Memory-202610.md +604 -0
  57. memory/checkpoint.py +331 -0
  58. memory/compactor.py +196 -0
  59. memory/consolidation.py +140 -0
  60. memory/decay_scheduler.py +119 -0
  61. memory/git_memory.py +360 -0
  62. memory/graph.py +156 -0
  63. memory/handoff.py +144 -0
  64. memory/heat.py +131 -0
  65. memory/hybrid.py +236 -0
  66. memory/ingest.py +244 -0
  67. memory/instincts.py +128 -0
  68. memory/observations.py +439 -0
  69. memory/schema_contract.py +361 -0
  70. memory/sectors.py +165 -0
  71. memory/simhash.py +164 -0
  72. memory/store.py +1383 -0
  73. memory/temporal.py +173 -0
  74. memory/vector.py +458 -0
  75. plugins/__init__.py +0 -0
  76. plugins/automatisch/__init__.py +5 -0
  77. plugins/automatisch/automatisch_plugin.py +78 -0
  78. plugins/brevo/__init__.py +5 -0
  79. plugins/brevo/brevo_plugin.py +78 -0
  80. plugins/chatwoot/__init__.py +5 -0
  81. plugins/chatwoot/chatwoot_plugin.py +78 -0
  82. plugins/code2video/README.md +87 -0
  83. plugins/code2video/__init__.py +3 -0
  84. plugins/code2video/code2video_plugin.py +229 -0
  85. plugins/code2video/entrypoint.py +166 -0
  86. plugins/code2video/litellm-c2v.yaml +30 -0
  87. plugins/context7/__init__.py +5 -0
  88. plugins/context7/context7_plugin.py +57 -0
  89. plugins/documenso/__init__.py +5 -0
  90. plugins/documenso/documenso_plugin.py +78 -0
  91. plugins/erpnext/__init__.py +5 -0
  92. plugins/erpnext/erpnext_plugin.py +78 -0
  93. plugins/fiverr/__init__.py +5 -0
  94. plugins/fiverr/fiverr_plugin.py +82 -0
  95. plugins/flagsmith/__init__.py +5 -0
  96. plugins/flagsmith/flagsmith_plugin.py +78 -0
  97. plugins/formbricks/__init__.py +5 -0
  98. plugins/formbricks/formbricks_plugin.py +78 -0
  99. plugins/freelancer/__init__.py +5 -0
  100. plugins/freelancer/freelancer_plugin.py +106 -0
  101. plugins/google_ads/__init__.py +5 -0
  102. plugins/google_ads/google_ads_plugin.py +78 -0
  103. plugins/graphify/__init__.py +3 -0
  104. plugins/graphify/graph_plugin.py +168 -0
  105. plugins/growthbook/__init__.py +5 -0
  106. plugins/growthbook/growthbook_plugin.py +78 -0
  107. plugins/hubspot/__init__.py +5 -0
  108. plugins/hubspot/hubspot_plugin.py +78 -0
  109. plugins/khamsat/__init__.py +5 -0
  110. plugins/khamsat/khamsat_plugin.py +80 -0
  111. plugins/kit/__init__.py +5 -0
  112. plugins/kit/kit_plugin.py +78 -0
  113. plugins/klaviyo/__init__.py +5 -0
  114. plugins/klaviyo/klaviyo_plugin.py +78 -0
  115. plugins/lago/__init__.py +5 -0
  116. plugins/lago/lago_plugin.py +78 -0
  117. plugins/linkedin/__init__.py +5 -0
  118. plugins/linkedin/linkedin_plugin.py +188 -0
  119. plugins/linkedin_ads/__init__.py +5 -0
  120. plugins/linkedin_ads/linkedin_ads_plugin.py +78 -0
  121. plugins/listmonk/__init__.py +5 -0
  122. plugins/listmonk/listmonk_plugin.py +78 -0
  123. plugins/meta_ads/__init__.py +5 -0
  124. plugins/meta_ads/meta_ads_plugin.py +78 -0
  125. plugins/mostaql/__init__.py +5 -0
  126. plugins/mostaql/mostaql_plugin.py +80 -0
  127. plugins/n8n/__init__.py +5 -0
  128. plugins/n8n/n8n_plugin.py +78 -0
  129. plugins/openreplay/__init__.py +5 -0
  130. plugins/openreplay/openreplay_plugin.py +78 -0
  131. plugins/posthog/__init__.py +5 -0
  132. plugins/posthog/posthog_plugin.py +78 -0
  133. plugins/postiz/__init__.py +5 -0
  134. plugins/postiz/postiz_plugin.py +78 -0
  135. plugins/sendgrid/__init__.py +5 -0
  136. plugins/sendgrid/sendgrid_plugin.py +78 -0
  137. plugins/tiktok_ads/__init__.py +5 -0
  138. plugins/tiktok_ads/tiktok_ads_plugin.py +78 -0
  139. plugins/twenty/__init__.py +5 -0
  140. plugins/twenty/twenty_plugin.py +78 -0
  141. plugins/twitter/__init__.py +5 -0
  142. plugins/twitter/twitter_plugin.py +78 -0
  143. plugins/upwork/__init__.py +5 -0
  144. plugins/upwork/upwork_plugin.py +90 -0
  145. plugins/youtube/__init__.py +5 -0
  146. plugins/youtube/youtube_plugin.py +78 -0
  147. runtime/__init__.py +694 -0
  148. runtime/_osroot.tar.gz +0 -0
  149. runtime/abstracts.py +159 -0
  150. runtime/agent_baseline.py +350 -0
  151. runtime/agent_circuit_breaker.py +305 -0
  152. runtime/agent_discovery.py +320 -0
  153. runtime/agent_gateway.py +448 -0
  154. runtime/agent_sli.py +435 -0
  155. runtime/api_session.py +229 -0
  156. runtime/approval_cache.py +61 -0
  157. runtime/approval_service.py +352 -0
  158. runtime/approval_sla.py +341 -0
  159. runtime/astryx.py +111 -0
  160. runtime/attribution_model.py +76 -0
  161. runtime/audit.py +641 -0
  162. runtime/audit_signing.py +346 -0
  163. runtime/billing_ledger.py +209 -0
  164. runtime/blade_template_linter.py +239 -0
  165. runtime/blast_radius.py +526 -0
  166. runtime/bootstrap.py +233 -0
  167. runtime/budget.py +800 -0
  168. runtime/budget_advanced.py +799 -0
  169. runtime/budget_escalation.py +220 -0
  170. runtime/c4_docs.py +200 -0
  171. runtime/call_plan.py +798 -0
  172. runtime/chat.py +63 -0
  173. runtime/ci.py +70 -0
  174. runtime/codemode/__init__.py +14 -0
  175. runtime/codemode/_child.py +123 -0
  176. runtime/codemode/executor.py +311 -0
  177. runtime/config_ops.py +116 -0
  178. runtime/context_composition.py +149 -0
  179. runtime/crm_manager.py +164 -0
  180. runtime/cross_tool_taint.py +529 -0
  181. runtime/crypto.py +195 -0
  182. runtime/curriculum.py +113 -0
  183. runtime/daemon.py +719 -0
  184. runtime/db_migration_safety.py +251 -0
  185. runtime/defensive_injection.py +472 -0
  186. runtime/deps.py +461 -0
  187. runtime/design_db.py +505 -0
  188. runtime/design_library.py +467 -0
  189. runtime/design_slop_verifier.py +540 -0
  190. runtime/dns_wire.py +200 -0
  191. runtime/doc_links.py +151 -0
  192. runtime/doctor.py +312 -0
  193. runtime/domain_intel.py +342 -0
  194. runtime/drip_engine.py +162 -0
  195. runtime/durable.py +443 -0
  196. runtime/enforcement.py +282 -0
  197. runtime/enums.py +67 -0
  198. runtime/evidence.py +97 -0
  199. runtime/experiment_tracker.py +184 -0
  200. runtime/fairness_detector.py +404 -0
  201. runtime/feature_flags.py +97 -0
  202. runtime/filament_access_auditor.py +232 -0
  203. runtime/find.py +150 -0
  204. runtime/funnel_tracker.py +128 -0
  205. runtime/gateway_proxy.py +335 -0
  206. runtime/governance.py +71 -0
  207. runtime/guardian.py +531 -0
  208. runtime/guardrails/__init__.py +11 -0
  209. runtime/guardrails/prompt_injection.py +143 -0
  210. runtime/hallucination_detector.py +398 -0
  211. runtime/harness_exporter.py +179 -0
  212. runtime/heal.py +316 -0
  213. runtime/hook_lifecycle.py +162 -0
  214. runtime/hooks.py +183 -0
  215. runtime/ingest_rules.py +106 -0
  216. runtime/injection_detector.py +794 -0
  217. runtime/inventory_note.py +165 -0
  218. runtime/kernel.py +1299 -0
  219. runtime/laravel_policy_linter.py +247 -0
  220. runtime/lead_scorer.py +86 -0
  221. runtime/lifecycle.py +158 -0
  222. runtime/llm_attestation.py +511 -0
  223. runtime/local_responder.py +133 -0
  224. runtime/loop_detector.py +394 -0
  225. runtime/managers/__init__.py +15 -0
  226. runtime/managers/agent_manager.py +106 -0
  227. runtime/managers/chat_manager.py +93 -0
  228. runtime/managers/policy_manager.py +353 -0
  229. runtime/managers/workflow_manager.py +136 -0
  230. runtime/marketing_compliance.py +74 -0
  231. runtime/mcp_auditor.py +701 -0
  232. runtime/mcp_client.py +743 -0
  233. runtime/mcp_firewall.py +381 -0
  234. runtime/mcp_freeze.py +114 -0
  235. runtime/mcp_manifest_lock.py +327 -0
  236. runtime/mcp_orchestrator.py +224 -0
  237. runtime/mcp_protocol.py +203 -0
  238. runtime/metrics.py +566 -0
  239. runtime/middleware.py +157 -0
  240. runtime/migrations.py +305 -0
  241. runtime/model_router.py +314 -0
  242. runtime/models_catalog.py +92 -0
  243. runtime/orchestrator.py +109 -0
  244. runtime/output_filter.py +87 -0
  245. runtime/overengineering.py +181 -0
  246. runtime/persona.py +524 -0
  247. runtime/personas.yaml +2130 -0
  248. runtime/pipeline_analytics.py +91 -0
  249. runtime/pipeline_catalog.py +263 -0
  250. runtime/plan_diff_validator.py +357 -0
  251. runtime/plugin.py +409 -0
  252. runtime/plugin_packager.py +158 -0
  253. runtime/plugin_transactions.py +151 -0
  254. runtime/policies/agentic-owasp.yaml +76 -0
  255. runtime/policies/consequence-tiers.yaml +92 -0
  256. runtime/policies/default.yaml +48 -0
  257. runtime/policies/examples/api-rate-limits.yaml +13 -0
  258. runtime/policies/examples/data-exfiltration.yaml +13 -0
  259. runtime/policies/examples/time-based-access.yaml +13 -0
  260. runtime/policies/guardian.yaml +101 -0
  261. runtime/policies/mcp_firewall.yaml +49 -0
  262. runtime/policies/probity.yaml +74 -0
  263. runtime/policy.py +558 -0
  264. runtime/policy_lint.py +255 -0
  265. runtime/post_queue.py +93 -0
  266. runtime/preloop.py +65 -0
  267. runtime/pricing_calculator.py +87 -0
  268. runtime/probity.py +211 -0
  269. runtime/project_config.py +111 -0
  270. runtime/prompt_gate.py +545 -0
  271. runtime/prompt_injection_detector.py +322 -0
  272. runtime/repository.py +113 -0
  273. runtime/rule_compiler.py +180 -0
  274. runtime/rule_frontmatter.py +180 -0
  275. runtime/rules_materializer.py +373 -0
  276. runtime/saga.py +213 -0
  277. runtime/schemas.py +299 -0
  278. runtime/secret_scrub.py +101 -0
  279. runtime/security_scanner.py +612 -0
  280. runtime/self_healing.py +116 -0
  281. runtime/sensitive.py +63 -0
  282. runtime/seo_issue_registry.py +349 -0
  283. runtime/service_catalog.py +476 -0
  284. runtime/settings.py +887 -0
  285. runtime/skill_lifecycle.py +352 -0
  286. runtime/skill_lint.py +344 -0
  287. runtime/skill_lock.py +213 -0
  288. runtime/skill_resolver.py +111 -0
  289. runtime/skill_scan.py +155 -0
  290. runtime/skill_validator.py +195 -0
  291. runtime/sovereign.py +80 -0
  292. runtime/spec/__init__.py +54 -0
  293. runtime/spec/analysis.py +280 -0
  294. runtime/spec/changes.py +179 -0
  295. runtime/spec/engine.py +660 -0
  296. runtime/spec/models.py +201 -0
  297. runtime/spec/review.py +298 -0
  298. runtime/spec/scaffold.py +131 -0
  299. runtime/spec/templates.py +80 -0
  300. runtime/spec_engine.py +61 -0
  301. runtime/stale_api_detector.py +256 -0
  302. runtime/storage_backend.py +561 -0
  303. runtime/supply_chain_guard.py +980 -0
  304. runtime/taint.py +390 -0
  305. runtime/task_contract/__init__.py +55 -0
  306. runtime/task_contract/classifier.py +102 -0
  307. runtime/task_contract/enforce.py +164 -0
  308. runtime/task_contract/engine.py +328 -0
  309. runtime/task_contract/evidence.py +145 -0
  310. runtime/task_contract/models.py +209 -0
  311. runtime/task_contract/validation.py +76 -0
  312. runtime/tech_stack.py +388 -0
  313. runtime/telemetry.py +162 -0
  314. runtime/tool_envelope.py +153 -0
  315. runtime/tool_output_sanitizer.py +171 -0
  316. runtime/tracing.py +212 -0
  317. runtime/tracing_otel.py +120 -0
  318. runtime/ui_a11y_checker.py +288 -0
  319. runtime/uninstaller.py +572 -0
  320. runtime/uninstaller_gui.py +498 -0
  321. runtime/verification.py +121 -0
  322. runtime/workflow.py +228 -0
@@ -0,0 +1,980 @@
1
+ #!/usr/bin/env python3
2
+ """Supply-chain guard: detect undeclared external imports.
3
+
4
+ Inspired by repo-contract's dependency guard. An AI agent commonly adds
5
+ ``import newpkg`` (or ``require``/``use``/``import`` in other ecosystems)
6
+ without declaring the package in the project's lockfile/manifest. This module
7
+ scans source files and diffs against the declared dependency set and reports
8
+ :class:`UndeclaredImport` findings.
9
+
10
+ Supported ecosystems: Python (``pyproject.toml``/``requirements.txt``),
11
+ Node (``package.json``), PHP (``composer.json``), Go (``go.mod``),
12
+ Rust (``Cargo.toml``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import logging
19
+ import re
20
+ import sys
21
+ import threading
22
+ from dataclasses import dataclass
23
+ from enum import Enum
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from runtime.schemas import AizeeError, ErrorSeverity, ValidationError
28
+
29
+ _log = logging.getLogger(__name__)
30
+
31
+
32
+ class DependencyEcosystem(str, Enum):
33
+ """Package ecosystem for a dependency or import."""
34
+
35
+ PYTHON = "python"
36
+ NODE = "node"
37
+ PHP = "php"
38
+ GO = "go"
39
+ RUST = "rust"
40
+
41
+
42
+ @dataclass
43
+ class DeclaredDependency:
44
+ """A dependency declared in a project manifest."""
45
+
46
+ name: str
47
+ version: str
48
+ ecosystem: DependencyEcosystem
49
+
50
+
51
+ @dataclass
52
+ class UndeclaredImport:
53
+ """An import of a module/package not declared in the project manifest."""
54
+
55
+ module: str
56
+ file: str
57
+ ecosystem: DependencyEcosystem
58
+ line: int = 0
59
+
60
+
61
+ # Python standard-library modules excluded from undeclared detection.
62
+ # sys.stdlib_module_names (3.10+) is the authoritative, complete list — a
63
+ # hand-rolled subset inevitably misses modules (gc, ipaddress, __future__…)
64
+ # and turns the guard into a false-positive machine.
65
+ _PYTHON_STDLIB: frozenset[str] = frozenset(sys.stdlib_module_names)
66
+
67
+ # Import name -> distribution package name for the common cases where they
68
+ # differ (``import yaml`` is satisfied by the ``pyyaml`` dependency).
69
+ _PY_IMPORT_TO_PKG: dict[str, str] = {
70
+ "yaml": "pyyaml",
71
+ "PIL": "pillow",
72
+ "cv2": "opencv-python",
73
+ "sklearn": "scikit-learn",
74
+ "bs4": "beautifulsoup4",
75
+ "serial": "pyserial",
76
+ "dotenv": "python-dotenv",
77
+ "jose": "python-jose",
78
+ "Crypto": "pycryptodome",
79
+ "attr": "attrs",
80
+ "dateutil": "python-dateutil",
81
+ "googleapiclient": "google-api-python-client",
82
+ "huggingface_hub": "huggingface-hub",
83
+ "OpenSSL": "pyopenssl",
84
+ "usb": "pyusb",
85
+ "git": "gitpython",
86
+ "magic": "python-magic",
87
+ "cairo": "pycairo",
88
+ "wx": "wxpython",
89
+ "dns": "dnspython",
90
+ "ldap": "python-ldap",
91
+ "nats": "nats-py",
92
+ "websocket": "websocket-client",
93
+ "yamlloader": "yamlloader",
94
+ }
95
+
96
+ # Sibling-file extensions per ecosystem — ``import helpers`` next to a
97
+ # ``helpers.ts``/``helpers.py`` is first-party, not an external package.
98
+ _SIBLING_EXTS: dict[DependencyEcosystem, tuple[str, ...]] = {
99
+ DependencyEcosystem.PYTHON: (".py",),
100
+ DependencyEcosystem.NODE: (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"),
101
+ DependencyEcosystem.PHP: (".php",),
102
+ DependencyEcosystem.GO: (".go",),
103
+ DependencyEcosystem.RUST: (".rs",),
104
+ }
105
+
106
+ # Regex for TS/JS import/require statements.
107
+ _TS_IMPORT_RE = re.compile(
108
+ r"""(?:import\s+.*?\s+from\s+['"]([^'"]+)['"])"""
109
+ r"""|(?:import\s+['"]([^'"]+)['"])"""
110
+ r"""|(?:require\(\s*['"]([^'"]+)['"]\s*\))""",
111
+ )
112
+
113
+ # Regex for PHP `use` statements.
114
+ _PHP_USE_RE = re.compile(r"^\s*use\s+([\w\\]+)", re.MULTILINE)
115
+
116
+ # Regex for Go import blocks / single imports.
117
+ _GO_IMPORT_RE = re.compile(
118
+ r"""^\s*import\s+(?:\(\s*([\s\S]*?)\s*\)|"([^"]+)")""",
119
+ re.MULTILINE,
120
+ )
121
+ _GO_IMPORT_LINE_RE = re.compile(r'"([^"]+)"')
122
+
123
+ # Regex for Rust `use` statements (use crate::module; use external_crate;).
124
+ _RUST_USE_RE = re.compile(r"^\s*use\s+([\w:]+)", re.MULTILINE)
125
+
126
+
127
+ class SupplyChainGuardError(AizeeError):
128
+ """Raised when the supply-chain guard encounters an internal error."""
129
+
130
+ def __init__(self, message: str, context: dict[str, Any] | None = None) -> None:
131
+ super().__init__("SUPPLY_CHAIN_ERROR", message, ErrorSeverity.HIGH, context)
132
+
133
+
134
+ class SupplyChainGuard:
135
+ """Detect imports of external packages not declared in project manifests.
136
+
137
+ Constructed with a project root; :meth:`load_declared` reads the manifests
138
+ present in that root, and :meth:`scan_imports`/:meth:`check_diff` report
139
+ imports whose top-level package is neither declared nor stdlib/builtin.
140
+ """
141
+
142
+ def __init__(self, project_root: Path) -> None:
143
+ self.project_root = Path(project_root)
144
+ self._declared: dict[DependencyEcosystem, set[str]] | None = None
145
+ self._lock = threading.RLock()
146
+ self._fp_dirs: set[str] | None = None
147
+ self._go_module: str | None = None
148
+ self._php_roots: set[str] | None = None
149
+ self._sibling_cache: dict[Path, dict[DependencyEcosystem, set[str]]] = {}
150
+
151
+ # -- declared dependency loading ----------------------------------------
152
+
153
+ def load_declared(self) -> dict[DependencyEcosystem, set[str]]:
154
+ """Read manifests in the project root and return declared package sets."""
155
+ with self._lock:
156
+ declared: dict[DependencyEcosystem, set[str]] = {
157
+ DependencyEcosystem.PYTHON: self._load_python_declared(),
158
+ DependencyEcosystem.NODE: self._load_node_declared(),
159
+ DependencyEcosystem.PHP: self._load_php_declared(),
160
+ DependencyEcosystem.GO: self._load_go_declared(),
161
+ DependencyEcosystem.RUST: self._load_rust_declared(),
162
+ }
163
+ self._declared = declared
164
+ return declared
165
+
166
+ def _load_python_declared(self) -> set[str]:
167
+ names: set[str] = set()
168
+ pyproject = self.project_root / "pyproject.toml"
169
+ if pyproject.exists():
170
+ names.update(_parse_pyproject_deps(pyproject.read_text(encoding="utf-8")))
171
+ reqs = self.project_root / "requirements.txt"
172
+ if reqs.exists():
173
+ names.update(_parse_requirements(reqs.read_text(encoding="utf-8")))
174
+ return names
175
+
176
+ def _load_node_declared(self) -> set[str]:
177
+ pkg = self.project_root / "package.json"
178
+ if not pkg.exists():
179
+ return set()
180
+ return _parse_package_json(pkg.read_text(encoding="utf-8"))
181
+
182
+ def _load_php_declared(self) -> set[str]:
183
+ composer = self.project_root / "composer.json"
184
+ if not composer.exists():
185
+ return set()
186
+ return _parse_composer_json(composer.read_text(encoding="utf-8"))
187
+
188
+ def _load_go_declared(self) -> set[str]:
189
+ gomod = self.project_root / "go.mod"
190
+ if not gomod.exists():
191
+ return set()
192
+ return _parse_go_mod(gomod.read_text(encoding="utf-8"))
193
+
194
+ def _load_rust_declared(self) -> set[str]:
195
+ cargo = self.project_root / "Cargo.toml"
196
+ if not cargo.exists():
197
+ return set()
198
+ return _parse_cargo_toml(cargo.read_text(encoding="utf-8"))
199
+
200
+ # -- import scanning ----------------------------------------------------
201
+
202
+ def scan_imports(self, file_path: Path) -> list[UndeclaredImport]:
203
+ """Scan a single file and return undeclared imports for its ecosystem."""
204
+ with self._lock:
205
+ path = Path(file_path)
206
+ if not path.exists():
207
+ raise ValidationError(f"File not found: {path}")
208
+ suffix = path.suffix.lower()
209
+ source = path.read_text(encoding="utf-8")
210
+ extracted = self._extract_for_suffix(source, suffix)
211
+ if extracted is None:
212
+ return []
213
+ ecosystem, modules = extracted
214
+ return self._build_findings(modules, ecosystem, str(path))
215
+
216
+ def scan_source(
217
+ self, source: str, filename: str = "<source>"
218
+ ) -> list[UndeclaredImport]:
219
+ """Scan in-memory source text for undeclared imports.
220
+
221
+ Used by ``lint_python`` (pre-write advisory): the submitted code has
222
+ no file on disk, so imports are extracted from the string and checked
223
+ against the project manifests exactly like ``scan_imports``.
224
+ """
225
+ with self._lock:
226
+ extracted = self._extract_for_suffix(source, Path(filename).suffix.lower())
227
+ if extracted is None:
228
+ return []
229
+ ecosystem, modules = extracted
230
+ return self._build_findings(modules, ecosystem, filename)
231
+
232
+ def _extract_for_suffix(
233
+ self, source: str, suffix: str
234
+ ) -> tuple[DependencyEcosystem, list[tuple[str, int]]] | None:
235
+ """Dispatch source text to the per-ecosystem import extractor."""
236
+ if suffix == ".py":
237
+ return DependencyEcosystem.PYTHON, self._extract_python_imports(source)
238
+ if suffix in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
239
+ return DependencyEcosystem.NODE, self._extract_ts_imports(source)
240
+ if suffix == ".php":
241
+ return DependencyEcosystem.PHP, self._extract_php_imports(source)
242
+ if suffix == ".go":
243
+ return DependencyEcosystem.GO, self._extract_go_imports(source)
244
+ if suffix == ".rs":
245
+ return DependencyEcosystem.RUST, self._extract_rust_imports(source)
246
+ return None
247
+
248
+ def check_file(self, file_path: Path) -> list[UndeclaredImport]:
249
+ """Alias for :meth:`scan_imports` (single-file check)."""
250
+ with self._lock:
251
+ return self.scan_imports(file_path)
252
+
253
+ def check_diff(self, diff_text: str) -> list[UndeclaredImport]:
254
+ """Extract added import lines from *diff_text* and check them."""
255
+ with self._lock:
256
+ findings: list[UndeclaredImport] = []
257
+ current_file: str | None = None
258
+ for line in diff_text.splitlines():
259
+ if line.startswith("+++ b/"):
260
+ current_file = line[6:]
261
+ elif line.startswith("+++") and current_file is None:
262
+ current_file = line[4:].strip() if len(line) > 4 else None
263
+ if not line.startswith("+") or line.startswith("+++"):
264
+ continue
265
+ if current_file is None:
266
+ continue
267
+ added = line[1:]
268
+ finding = self._check_diff_line(added, current_file)
269
+ if finding is not None:
270
+ findings.append(finding)
271
+ return findings
272
+
273
+ def _check_diff_line(self, added: str, file_path: str) -> UndeclaredImport | None:
274
+ suffix = Path(file_path).suffix.lower()
275
+ if suffix == ".py":
276
+ modules = self._extract_python_imports(added)
277
+ ecosystem = DependencyEcosystem.PYTHON
278
+ elif suffix in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
279
+ modules = self._extract_ts_imports(added)
280
+ ecosystem = DependencyEcosystem.NODE
281
+ elif suffix == ".php":
282
+ modules = self._extract_php_imports(added)
283
+ ecosystem = DependencyEcosystem.PHP
284
+ elif suffix == ".go":
285
+ modules = self._extract_go_imports(added)
286
+ ecosystem = DependencyEcosystem.GO
287
+ else:
288
+ return None
289
+ if not modules:
290
+ return None
291
+ undeclared = self._build_findings(modules, ecosystem, file_path)
292
+ return undeclared[0] if undeclared else None
293
+
294
+ def _build_findings(
295
+ self,
296
+ modules: list[tuple[str, int]],
297
+ ecosystem: DependencyEcosystem,
298
+ file_str: str,
299
+ ) -> list[UndeclaredImport]:
300
+ declared = self._ensure_declared()
301
+ allowed = declared.get(ecosystem, set())
302
+ findings: list[UndeclaredImport] = []
303
+ base_dir = (
304
+ None if file_str.startswith("<") else Path(file_str).parent
305
+ )
306
+ seen: set[str] = set()
307
+ for module, line_no in modules:
308
+ top = self._top_level_name(module, ecosystem)
309
+ if top in seen or self._is_allowed(top, ecosystem, allowed, base_dir):
310
+ continue
311
+ seen.add(top)
312
+ findings.append(
313
+ UndeclaredImport(
314
+ module=module,
315
+ file=file_str,
316
+ ecosystem=ecosystem,
317
+ line=line_no,
318
+ )
319
+ )
320
+ return findings
321
+
322
+ def _ensure_declared(self) -> dict[DependencyEcosystem, set[str]]:
323
+ if self._declared is None:
324
+ return self.load_declared()
325
+ return self._declared
326
+
327
+ @staticmethod
328
+ def _top_level_name(module: str, ecosystem: DependencyEcosystem) -> str:
329
+ if ecosystem == DependencyEcosystem.NODE:
330
+ if module.startswith("@"):
331
+ parts = module.split("/", 2)
332
+ return "/".join(parts[:2]) if len(parts) > 1 else module
333
+ return module.split("/", 1)[0]
334
+ if ecosystem == DependencyEcosystem.PHP:
335
+ return module.split("\\", 1)[0]
336
+ if ecosystem == DependencyEcosystem.GO:
337
+ return module
338
+ return module.split(".", 1)[0]
339
+
340
+ def _is_allowed(
341
+ self,
342
+ top: str,
343
+ ecosystem: DependencyEcosystem,
344
+ declared: set[str],
345
+ base_dir: Path | None = None,
346
+ ) -> bool:
347
+ if not top:
348
+ return True
349
+ if self._is_first_party(top, ecosystem, base_dir):
350
+ return True
351
+ if ecosystem == DependencyEcosystem.PYTHON:
352
+ if top in _PYTHON_STDLIB or top.startswith("."):
353
+ return True
354
+ normalized = {d.replace("-", "_").lower() for d in declared}
355
+ if top.replace("-", "_").lower() in normalized:
356
+ return True
357
+ alias = _PY_IMPORT_TO_PKG.get(top)
358
+ return alias is not None and alias.replace("-", "_").lower() in normalized
359
+ if ecosystem == DependencyEcosystem.NODE and (
360
+ top.startswith(".") or top.startswith("/")
361
+ or top.startswith("node:") or top.startswith("@/")
362
+ ):
363
+ return True
364
+ if ecosystem == DependencyEcosystem.PHP:
365
+ # PHP `use` namespaces map to composer vendor prefixes case-insensitively.
366
+ top_lower = top.lower()
367
+ for dep in declared:
368
+ vendor = dep.split("/", 1)[0].lower()
369
+ if top_lower == vendor:
370
+ return True
371
+ return False
372
+ if ecosystem == DependencyEcosystem.GO:
373
+ # Go stdlib detection is non-trivial; only declared deps are allowed.
374
+ return top in declared
375
+ return top in declared
376
+
377
+ # -- first-party detection ---------------------------------------------
378
+
379
+ def _is_first_party(
380
+ self,
381
+ top: str,
382
+ ecosystem: DependencyEcosystem,
383
+ base_dir: Path | None = None,
384
+ ) -> bool:
385
+ """True when ``top`` resolves inside the project tree itself.
386
+
387
+ Local packages (``runtime/``, ``memory/``, ``config.py`` ...) are not
388
+ external dependencies — without this check every first-party import
389
+ is a false positive. Sibling modules (``import core`` next to
390
+ ``core.py``) count too via ``base_dir``. Go internal imports share
391
+ the ``module`` path; PHP first-party namespaces come from composer
392
+ ``autoload`` psr-4 roots.
393
+ """
394
+ if base_dir is not None and top in self._sibling_names(base_dir, ecosystem):
395
+ return True
396
+ if ecosystem is DependencyEcosystem.GO:
397
+ module = self._go_module_path()
398
+ return bool(module) and (top == module or top.startswith(module + "/"))
399
+ if ecosystem is DependencyEcosystem.PHP:
400
+ return top.lower() in self._php_autoload_roots()
401
+ roots = self._first_party_dirs()
402
+ return top in roots or top.replace("-", "_") in roots
403
+
404
+ def _first_party_dirs(self) -> set[str]:
405
+ """Top-level dir names + ``*.py`` stems + src-layout package names."""
406
+ if self._fp_dirs is not None:
407
+ return self._fp_dirs
408
+ names: set[str] = set()
409
+ try:
410
+ for p in self.project_root.iterdir():
411
+ if p.is_dir() and not p.name.startswith((".", "_")):
412
+ names.add(p.name)
413
+ elif p.suffix == ".py":
414
+ names.add(p.stem)
415
+ for layout in ("src", "app", "lib"):
416
+ sub = self.project_root / layout
417
+ if sub.is_dir():
418
+ names.update(c.name for c in sub.iterdir() if c.is_dir())
419
+ names.update(c.stem for c in sub.glob("*.py"))
420
+ except OSError as exc:
421
+ _log.debug("supply-chain: root scan failed for %s: %s", self.project_root, exc)
422
+ self._fp_dirs = names
423
+ return names
424
+
425
+ def _sibling_names(self, base_dir: Path, ecosystem: DependencyEcosystem) -> set[str]:
426
+ """Importable names beside the importing file (and one subdir deep).
427
+
428
+ Catches ``import helpers`` next to ``helpers.py`` and the common
429
+ ``scripts/``-style layout where a tool imports modules it placed in
430
+ an adjacent directory. Cached per directory.
431
+ """
432
+ cache = self._sibling_cache.setdefault(base_dir, {})
433
+ if ecosystem in cache:
434
+ return cache[ecosystem]
435
+ exts = _SIBLING_EXTS.get(ecosystem, (".py",))
436
+ names: set[str] = set()
437
+ try:
438
+ dirs = [base_dir]
439
+ dirs.extend(p for p in base_dir.iterdir() if p.is_dir() and not p.name.startswith((".", "_")))
440
+ for d in dirs:
441
+ names.update(
442
+ p.stem for ext in exts for p in d.glob(f"*{ext}")
443
+ )
444
+ names.update(p.name for p in d.iterdir() if p.is_dir() and not p.name.startswith((".", "_")))
445
+ except OSError as exc:
446
+ _log.debug("supply-chain: sibling scan failed for %s: %s", base_dir, exc)
447
+ cache[ecosystem] = names
448
+ return names
449
+
450
+ def _go_module_path(self) -> str:
451
+ """The ``module`` path declared in go.mod (``""`` when absent)."""
452
+ if self._go_module is not None:
453
+ return self._go_module
454
+ module = ""
455
+ gomod = self.project_root / "go.mod"
456
+ if gomod.is_file():
457
+ try:
458
+ for line in gomod.read_text(encoding="utf-8").splitlines():
459
+ stripped = line.strip()
460
+ if stripped.startswith("module "):
461
+ module = stripped[len("module "):].strip()
462
+ break
463
+ except OSError:
464
+ module = ""
465
+ self._go_module = module
466
+ return module
467
+
468
+ def _php_autoload_roots(self) -> set[str]:
469
+ """First-party PHP namespace roots from composer.json autoload psr-4."""
470
+ if self._php_roots is not None:
471
+ return self._php_roots
472
+ import json as _json
473
+
474
+ roots: set[str] = set()
475
+ composer = self.project_root / "composer.json"
476
+ if composer.is_file():
477
+ try:
478
+ data = _json.loads(composer.read_text(encoding="utf-8"))
479
+ except (ValueError, OSError):
480
+ data = {}
481
+ for key in ("autoload", "autoload-dev"):
482
+ section = data.get(key) or {}
483
+ if isinstance(section, dict):
484
+ for ns in section.get("psr-4") or {}:
485
+ roots.add(str(ns).strip("\\").split("\\")[0].lower())
486
+ self._php_roots = roots
487
+ return roots
488
+
489
+ # -- per-language extractors --------------------------------------------
490
+
491
+ def _extract_python_imports(self, source: str) -> list[tuple[str, int]]:
492
+ MAX_SOURCE_SIZE = 10 * 1024 * 1024 # 10MB # noqa: N806
493
+ if len(source) > MAX_SOURCE_SIZE:
494
+ return []
495
+ try:
496
+ tree = ast.parse(source)
497
+ except SyntaxError:
498
+ return []
499
+ modules: list[tuple[str, int]] = []
500
+ for node in ast.walk(tree):
501
+ if isinstance(node, ast.Import):
502
+ for alias in node.names:
503
+ modules.append((alias.name, node.lineno))
504
+ elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
505
+ modules.append((node.module, node.lineno))
506
+ return modules
507
+
508
+ def _extract_ts_imports(self, source: str) -> list[tuple[str, int]]:
509
+ modules: list[tuple[str, int]] = []
510
+ for line_no, line in enumerate(source.splitlines(), start=1):
511
+ for match in _TS_IMPORT_RE.finditer(line):
512
+ name = match.group(1) or match.group(2) or match.group(3)
513
+ if name: # pragma: no branch - every alternative captures a module name
514
+ modules.append((name, line_no))
515
+ return modules
516
+
517
+ def _extract_php_imports(self, source: str) -> list[tuple[str, int]]:
518
+ modules: list[tuple[str, int]] = []
519
+ for match in _PHP_USE_RE.finditer(source):
520
+ line_no = source.count("\n", 0, match.start()) + 1
521
+ modules.append((match.group(1), line_no))
522
+ return modules
523
+
524
+ def _extract_go_imports(self, source: str) -> list[tuple[str, int]]:
525
+ modules: list[tuple[str, int]] = []
526
+ for match in _GO_IMPORT_RE.finditer(source):
527
+ line_no = source.count("\n", 0, match.start()) + 1
528
+ block = match.group(1)
529
+ single = match.group(2)
530
+ if block:
531
+ for line_match in _GO_IMPORT_LINE_RE.finditer(block):
532
+ modules.append((line_match.group(1), line_no))
533
+ elif single:
534
+ modules.append((single, line_no))
535
+ return modules
536
+
537
+ def _extract_rust_imports(self, source: str) -> list[tuple[str, int]]:
538
+ """Extract crate names from Rust ``use`` statements.
539
+
540
+ Handles ``use foo::bar``, ``use foo``, and ``use foo::{bar, baz}``.
541
+ Only the top-level crate name (before the first ``::``) is kept,
542
+ since that's what corresponds to a Cargo dependency.
543
+ """
544
+ modules: list[tuple[str, int]] = []
545
+ for match in _RUST_USE_RE.finditer(source):
546
+ line_no = source.count("\n", 0, match.start()) + 1
547
+ path = match.group(1)
548
+ # Take only the top-level crate name (before first ::).
549
+ top = path.split("::")[0]
550
+ # Skip std/core/alloc - Rust prelude/stdlib crates.
551
+ if top in ("std", "core", "alloc", "self", "crate", "super"):
552
+ continue
553
+ modules.append((top, line_no))
554
+ return modules
555
+
556
+
557
+ def _extract_dependency_name(spec: str) -> str | None:
558
+ """Extract a bare dependency name from a manifest spec line."""
559
+ # Strip environment markers / extras: requests>=2.0 ; python_version<"3.10"
560
+ spec = spec.split(";")[0].strip()
561
+ if not spec:
562
+ return None
563
+ # Strip extras: package[extra]==1.0 -> package
564
+ spec = spec.split("[")[0]
565
+ for sep in ("==", ">=", "<=", "!=", "~=", ">", "<", "="):
566
+ if sep in spec:
567
+ spec = spec.split(sep, 1)[0]
568
+ break
569
+ name = spec.strip()
570
+ if not name or name.startswith("-"):
571
+ return None
572
+ return name
573
+
574
+
575
+ def _parse_pyproject_deps(text: str) -> set[str]:
576
+ """Parse dependency names from pyproject.toml (naive, tomllib-free).
577
+
578
+ Handles two layouts: ``[tool.poetry.dependencies]`` section headers and
579
+ ``dependencies = [...]`` array keys under ``[project]``.
580
+ """
581
+ names: set[str] = set()
582
+ in_section = False
583
+ in_array = False
584
+ for line in text.splitlines():
585
+ stripped = line.strip()
586
+ if stripped.startswith("["):
587
+ in_section = "dependencies" in stripped
588
+ in_array = False
589
+ continue
590
+ if in_section:
591
+ _collect_dep_names(stripped, names)
592
+ continue
593
+ if stripped.startswith("dependencies") and "=" in stripped:
594
+ in_array = "]" not in stripped
595
+ _collect_dep_names(stripped, names)
596
+ elif in_array:
597
+ _collect_dep_names(stripped, names)
598
+ if "]" in stripped:
599
+ in_array = False
600
+ return names
601
+
602
+
603
+ def _collect_dep_names(stripped: str, names: set[str]) -> None:
604
+ """Extract dependency names from quoted strings in a line.
605
+
606
+ Poetry/TOML key-value lines (``requests = "^2.0"``) carry the dependency
607
+ name as the unquoted key - collect the key, not the version spec.
608
+ """
609
+ quoted = re.findall(r'["\']([^"\']+)["\']', stripped)
610
+ key_match = re.match(r"^([A-Za-z0-9_.\-]+)\s*=", stripped)
611
+ if key_match and len(quoted) <= 1 and key_match.group(1) != "dependencies":
612
+ names.add(key_match.group(1))
613
+ return
614
+ for q in quoted:
615
+ name = _extract_dependency_name(q)
616
+ if name:
617
+ names.add(name)
618
+
619
+
620
+ def _parse_requirements(text: str) -> set[str]:
621
+ """Parse dependency names from a requirements.txt file."""
622
+ names: set[str] = set()
623
+ for line in text.splitlines():
624
+ stripped = line.strip()
625
+ if not stripped or stripped.startswith("#") or stripped.startswith("-"):
626
+ continue
627
+ name = _extract_dependency_name(stripped)
628
+ if name:
629
+ names.add(name)
630
+ return names
631
+
632
+
633
+ def _parse_package_json(text: str) -> set[str]:
634
+ """Parse dependency names from a package.json file."""
635
+ import json as _json
636
+
637
+ try:
638
+ data = _json.loads(text)
639
+ except _json.JSONDecodeError:
640
+ return set()
641
+ names: set[str] = set()
642
+ for key in ("dependencies", "devDependencies", "peerDependencies"):
643
+ deps = data.get(key) or {}
644
+ if isinstance(deps, dict):
645
+ names.update(deps.keys())
646
+ return names
647
+
648
+
649
+ def _parse_composer_json(text: str) -> set[str]:
650
+ """Parse dependency names from a composer.json file."""
651
+ import json as _json
652
+
653
+ try:
654
+ data = _json.loads(text)
655
+ except _json.JSONDecodeError:
656
+ return set()
657
+ names: set[str] = set()
658
+ for key in ("require", "require-dev"):
659
+ deps = data.get(key) or {}
660
+ if isinstance(deps, dict):
661
+ names.update(deps.keys())
662
+ return names
663
+
664
+
665
+ def _parse_go_mod(text: str) -> set[str]:
666
+ """Parse dependency module paths from a go.mod file."""
667
+ names: set[str] = set()
668
+ in_require = False
669
+ for line in text.splitlines():
670
+ stripped = line.strip()
671
+ if stripped.startswith("require ("):
672
+ in_require = True
673
+ continue
674
+ if in_require and stripped == ")":
675
+ in_require = False
676
+ continue
677
+ if in_require:
678
+ parts = stripped.split()
679
+ if parts:
680
+ names.add(parts[0])
681
+ elif stripped.startswith("require "):
682
+ parts = stripped[len("require "):].split()
683
+ if parts: # pragma: no branch - stripped() guarantees content after "require "
684
+ names.add(parts[0])
685
+ return names
686
+
687
+
688
+ def _parse_cargo_toml(text: str) -> set[str]:
689
+ """Parse dependency names from a Cargo.toml file (naive, tomllib-free).
690
+
691
+ Handles ``[dependencies]`` and ``[dev-dependencies]`` sections with both
692
+ inline (``serde = "1.0"``) and table (``serde = { version = "1.0" }``) forms.
693
+ """
694
+ names: set[str] = set()
695
+ in_section = False
696
+ for line in text.splitlines():
697
+ stripped = line.strip()
698
+ if stripped.startswith("["):
699
+ in_section = stripped in ("[dependencies]", "[dev-dependencies]")
700
+ continue
701
+ if not in_section or not stripped or stripped.startswith("#"):
702
+ continue
703
+ # Extract the key before '=' (the crate name).
704
+ if "=" in stripped:
705
+ key = stripped.split("=", 1)[0].strip()
706
+ # Skip non-name keys like 'workspace = true'.
707
+ if key and not key.startswith("_") and key.replace("-", "_").isidentifier():
708
+ names.add(key)
709
+ return names
710
+
711
+
712
+ # -- Typosquat Detection (from AgentGuard) ---------------------------------
713
+
714
+
715
+ # Common homoglyph pairs for visual confusion attacks.
716
+ _HOMOGLYPHS: dict[str, list[str]] = {
717
+ "l": ["1", "I", "|"],
718
+ "I": ["l", "1", "|"],
719
+ "0": ["O", "o"],
720
+ "o": ["0"],
721
+ "rn": ["m"],
722
+ "vv": ["w"],
723
+ "cl": ["d"],
724
+ "nn": ["m"],
725
+ "cj": ["g"],
726
+ }
727
+
728
+
729
+ def _levenshtein(s1: str, s2: str) -> int:
730
+ """Compute Levenshtein edit distance between two strings."""
731
+ if len(s1) < len(s2):
732
+ return _levenshtein(s2, s1)
733
+ if len(s2) == 0:
734
+ return len(s1)
735
+ prev_row = list(range(len(s2) + 1))
736
+ for i, c1 in enumerate(s1):
737
+ curr_row = [i + 1]
738
+ for j, c2 in enumerate(s2):
739
+ insertions = prev_row[j + 1] + 1
740
+ deletions = curr_row[j] + 1
741
+ substitutions = prev_row[j] + (c1 != c2)
742
+ curr_row.append(min(insertions, deletions, substitutions))
743
+ prev_row = curr_row
744
+ return prev_row[-1]
745
+
746
+
747
+ @dataclass
748
+ class TyposquatFinding:
749
+ """A potential typosquatting detection result."""
750
+
751
+ package: str
752
+ suspected_of: str # The legitimate package it imitates
753
+ distance: int
754
+ ecosystem: DependencyEcosystem
755
+ reason: str # "edit_distance" or "homoglyph"
756
+
757
+
758
+ class TyposquatDetector:
759
+ """Detect typosquatted package names using edit distance + homoglyphs.
760
+
761
+ Compares a package name against a list of known-popular packages.
762
+ Flags names with edit distance <= threshold or homoglyph substitutions.
763
+ """
764
+
765
+ DEFAULT_THRESHOLD: int = 2
766
+
767
+ # A small default set of popular packages. Projects should override
768
+ # with a more comprehensive list.
769
+ DEFAULT_POPULAR_PYTHON: frozenset[str] = frozenset({
770
+ "requests", "numpy", "pandas", "flask", "django", "fastapi",
771
+ "pydantic", "rich", "click", "pytest", "mypy", "ruff", "black",
772
+ "pillow", "sqlalchemy", "alembic", "celery", "redis", "boto3",
773
+ "openai", "anthropic", "langchain", "llama-index", "transformers",
774
+ "torch", "tensorflow", "scipy", "matplotlib", "seaborn", "scikit-learn",
775
+ "pyyaml", "tomli", "httpx", "aiohttp", "uvicorn", "gunicorn",
776
+ })
777
+ DEFAULT_POPULAR_NODE: frozenset[str] = frozenset({
778
+ "react", "vue", "angular", "express", "next", "lodash", "axios",
779
+ "chalk", "commander", "typescript", "webpack", "vite", "eslint",
780
+ "prettier", "jest", "vitest", "zod", "prisma", "trpc", "tailwindcss",
781
+ "framer-motion", "radix-ui", "shadcn-ui", "class-variance-authority",
782
+ })
783
+
784
+ def __init__(
785
+ self,
786
+ threshold: int = DEFAULT_THRESHOLD,
787
+ popular_python: set[str] | None = None,
788
+ popular_node: set[str] | None = None,
789
+ ) -> None:
790
+ self.threshold = threshold
791
+ self._popular: dict[DependencyEcosystem, set[str]] = {
792
+ DependencyEcosystem.PYTHON: popular_python or set(self.DEFAULT_POPULAR_PYTHON),
793
+ DependencyEcosystem.NODE: popular_node or set(self.DEFAULT_POPULAR_NODE),
794
+ }
795
+
796
+ def check(self, package: str, ecosystem: DependencyEcosystem) -> list[TyposquatFinding]:
797
+ """Check a package name for typosquatting against known-popular packages."""
798
+ popular = self._popular.get(ecosystem, set())
799
+ if package in popular:
800
+ return [] # Exact match - legitimate
801
+ findings: list[TyposquatFinding] = []
802
+ for legit in popular:
803
+ # Edit distance check
804
+ dist = _levenshtein(package.lower(), legit.lower())
805
+ if 0 < dist <= self.threshold:
806
+ findings.append(TyposquatFinding(
807
+ package=package,
808
+ suspected_of=legit,
809
+ distance=dist,
810
+ ecosystem=ecosystem,
811
+ reason="edit_distance",
812
+ ))
813
+ continue
814
+ # Homoglyph check
815
+ if self._has_homoglyph_match(package, legit):
816
+ findings.append(TyposquatFinding(
817
+ package=package,
818
+ suspected_of=legit,
819
+ distance=dist,
820
+ ecosystem=ecosystem,
821
+ reason="homoglyph",
822
+ ))
823
+ return findings
824
+
825
+ @staticmethod
826
+ def _has_homoglyph_match(candidate: str, legit: str) -> bool:
827
+ """Check if candidate differs from legit only by homoglyph substitutions."""
828
+ if len(candidate) != len(legit):
829
+ return False
830
+ diffs = 0
831
+ for c1, c2 in zip(candidate, legit, strict=False):
832
+ if c1 == c2:
833
+ continue
834
+ if (c2 in _HOMOGLYPHS and c1 in _HOMOGLYPHS[c2]) or (
835
+ c1 in _HOMOGLYPHS and c2 in _HOMOGLYPHS[c1]
836
+ ):
837
+ diffs += 1
838
+ else:
839
+ return False # Non-homoglyph difference
840
+ return diffs > 0
841
+
842
+
843
+ # -- OSV.dev Vulnerability Feed (from AgentGuard) ---------------------------
844
+
845
+
846
+ @dataclass
847
+ class VulnerabilityAdvisory:
848
+ """A vulnerability advisory from OSV.dev or another feed."""
849
+
850
+ package: str
851
+ ecosystem: DependencyEcosystem
852
+ advisory_id: str # e.g., "MAL-2024-1234", "GHSA-xxxx", "CVE-2024-xxxx"
853
+ severity: str # "CRITICAL", "HIGH", "MEDIUM", "LOW"
854
+ summary: str
855
+ url: str = ""
856
+
857
+
858
+ class OsvDevClient:
859
+ """Client for querying the OSV.dev vulnerability database.
860
+
861
+ Uses urllib (stdlib) to query https://api.osv.dev/v1/query. Results
862
+ are cached for 1 hour to avoid rate limiting. If the API is
863
+ unreachable, raises ``SupplyChainGuardError`` (fail-closed: a network
864
+ failure must NOT be silently treated as "no vulnerabilities").
865
+ """
866
+
867
+ CACHE_TTL_SECONDS: int = 3600 # 1 hour
868
+ API_URL: str = "https://api.osv.dev/v1/query"
869
+
870
+ def __init__(self, cache_ttl: int = CACHE_TTL_SECONDS) -> None:
871
+ self._cache: dict[str, tuple[float, list[VulnerabilityAdvisory]]] = {}
872
+ self._cache_ttl = cache_ttl
873
+
874
+ def query(
875
+ self, package: str, ecosystem: DependencyEcosystem, version: str | None = None,
876
+ ) -> list[VulnerabilityAdvisory]:
877
+ """Query OSV.dev for vulnerabilities affecting a package.
878
+
879
+ Returns a list of advisories. Raises ``SupplyChainGuardError`` on
880
+ network/parse failure (fail-closed - never silently pass vulnerable
881
+ packages when OSV.dev is unreachable).
882
+ """
883
+ import time as _time
884
+
885
+ eco_str = self._ecosystem_str(ecosystem)
886
+ cache_key = f"{eco_str}:{package}:{version or ''}"
887
+ now = _time.time()
888
+ cached = self._cache.get(cache_key)
889
+ if cached and (now - cached[0]) < self._cache_ttl:
890
+ return cached[1]
891
+
892
+ advisories = self._fetch(cache_key, package, eco_str, version)
893
+ self._cache[cache_key] = (now, advisories)
894
+ return advisories
895
+
896
+ def _fetch(
897
+ self, cache_key: str, package: str, eco_str: str, version: str | None,
898
+ ) -> list[VulnerabilityAdvisory]:
899
+ """Fetch from OSV.dev API.
900
+
901
+ Raises ``SupplyChainGuardError`` on network/parse failure (fail-closed).
902
+ """
903
+ import json as _json
904
+ import urllib.error
905
+ import urllib.request
906
+
907
+ payload: dict[str, Any] = {"package": {"name": package, "ecosystem": eco_str}}
908
+ if version:
909
+ payload["version"] = version
910
+ body = _json.dumps(payload).encode("utf-8")
911
+ req = urllib.request.Request(
912
+ self.API_URL, data=body, headers={"Content-Type": "application/json"}
913
+ )
914
+ try:
915
+ with urllib.request.urlopen(req, timeout=10) as resp:
916
+ data = _json.loads(resp.read().decode("utf-8"))
917
+ except (urllib.error.URLError, TimeoutError, _json.JSONDecodeError) as exc:
918
+ # Fail-closed: a network/parse failure must NOT be silently treated
919
+ # as "no vulnerabilities". Returning [] would let vulnerable
920
+ # packages pass checks when OSV.dev is unreachable. Raise so the
921
+ # caller can decide (e.g. block, warn, or treat as unknown).
922
+ raise SupplyChainGuardError(
923
+ f"OSV.dev query failed for {package}@{version or 'latest'}: "
924
+ f"{type(exc).__name__}: {exc}",
925
+ context={"package": package, "ecosystem": eco_str, "version": version},
926
+ ) from exc
927
+
928
+ advisories: list[VulnerabilityAdvisory] = []
929
+ for vuln in data.get("vulns", []):
930
+ sev = "MEDIUM"
931
+ # Check CVSS_V3 severity vector
932
+ for s in vuln.get("severity", []):
933
+ if s.get("type") == "CVSS_V3":
934
+ score_str = s.get("score", "")
935
+ if "CVSS:3" in score_str:
936
+ sev = "HIGH"
937
+ advisory_id = vuln.get("id", "")
938
+ # MAL-* advisories are confirmed malicious packages -> always CRITICAL
939
+ if advisory_id.startswith("MAL-"):
940
+ sev = "CRITICAL"
941
+ advisories.append(VulnerabilityAdvisory(
942
+ package=package,
943
+ ecosystem=self._str_to_ecosystem(eco_str),
944
+ advisory_id=advisory_id,
945
+ severity=sev,
946
+ summary=vuln.get("summary", ""),
947
+ url=f"https://osv.dev/{advisory_id}",
948
+ ))
949
+ return advisories
950
+
951
+ @staticmethod
952
+ def _ecosystem_str(ecosystem: DependencyEcosystem) -> str:
953
+ return {
954
+ DependencyEcosystem.PYTHON: "PyPI",
955
+ DependencyEcosystem.NODE: "npm",
956
+ DependencyEcosystem.PHP: "Packagist",
957
+ DependencyEcosystem.GO: "Go",
958
+ }.get(ecosystem, "PyPI")
959
+
960
+ @staticmethod
961
+ def _str_to_ecosystem(eco_str: str) -> DependencyEcosystem:
962
+ return {
963
+ "PyPI": DependencyEcosystem.PYTHON,
964
+ "npm": DependencyEcosystem.NODE,
965
+ "Packagist": DependencyEcosystem.PHP,
966
+ "Go": DependencyEcosystem.GO,
967
+ }.get(eco_str, DependencyEcosystem.PYTHON)
968
+
969
+
970
+ __all__ = [
971
+ "DeclaredDependency",
972
+ "DependencyEcosystem",
973
+ "OsvDevClient",
974
+ "SupplyChainGuard",
975
+ "SupplyChainGuardError",
976
+ "TyposquatDetector",
977
+ "TyposquatFinding",
978
+ "UndeclaredImport",
979
+ "VulnerabilityAdvisory",
980
+ ]