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.
- aizee-6.8.0.dist-info/METADATA +617 -0
- aizee-6.8.0.dist-info/RECORD +322 -0
- aizee-6.8.0.dist-info/WHEEL +5 -0
- aizee-6.8.0.dist-info/entry_points.txt +2 -0
- aizee-6.8.0.dist-info/licenses/LICENSE +21 -0
- aizee-6.8.0.dist-info/licenses/NOTICE +33 -0
- aizee-6.8.0.dist-info/top_level.txt +7 -0
- aizee_cli.py +2907 -0
- aizee_mcp/API.md +353 -0
- aizee_mcp/__init__.py +3 -0
- aizee_mcp/_compat.py +50 -0
- aizee_mcp/adapters.py +555 -0
- aizee_mcp/agent.py +208 -0
- aizee_mcp/aizee_server.py +501 -0
- aizee_mcp/config.json +29 -0
- aizee_mcp/rbac.py +109 -0
- aizee_mcp/rbac.yaml +25 -0
- aizee_mcp/schema_profile.py +74 -0
- aizee_mcp/tools/__init__.py +94 -0
- aizee_mcp/tools/ads_tools.py +285 -0
- aizee_mcp/tools/analytics_tools.py +408 -0
- aizee_mcp/tools/common.py +103 -0
- aizee_mcp/tools/context_tools.py +140 -0
- aizee_mcp/tools/cro_tools.py +295 -0
- aizee_mcp/tools/doctor_tools.py +69 -0
- aizee_mcp/tools/email_tools.py +555 -0
- aizee_mcp/tools/freelance_tools.py +300 -0
- aizee_mcp/tools/intel_tools.py +114 -0
- aizee_mcp/tools/memory_tools.py +236 -0
- aizee_mcp/tools/policy_tools.py +135 -0
- aizee_mcp/tools/schemas.py +296 -0
- aizee_mcp/tools/seo_page_reporters.py +181 -0
- aizee_mcp/tools/seo_tools.py +1047 -0
- aizee_mcp/tools/social_tools.py +358 -0
- aizee_mcp/tools/task_tools.py +135 -0
- aizee_mcp/tools/workflow_tools.py +209 -0
- config.py +64 -0
- eval/__init__.py +0 -0
- eval/agent_benchmark.py +268 -0
- eval/chaos.py +296 -0
- eval/harness.py +234 -0
- eval/pipeline.py +460 -0
- eval/prompt_injection_suite.py +385 -0
- eval/redteam.py +284 -0
- eval/release_gate.py +157 -0
- eval/reliability.py +510 -0
- eval/rubric.py +263 -0
- eval/scenarios/goldfish-review.yaml +61 -0
- eval/scenarios/persona.yaml +22 -0
- eval/scenarios/security.yaml +43 -0
- eval/skill_scenarios.py +235 -0
- eval/stages.py +135 -0
- eval/trigger_cases.json +118 -0
- eval/vibe.py +234 -0
- memory/__init__.py +22 -0
- memory/archive/Memory-202610.md +604 -0
- memory/checkpoint.py +331 -0
- memory/compactor.py +196 -0
- memory/consolidation.py +140 -0
- memory/decay_scheduler.py +119 -0
- memory/git_memory.py +360 -0
- memory/graph.py +156 -0
- memory/handoff.py +144 -0
- memory/heat.py +131 -0
- memory/hybrid.py +236 -0
- memory/ingest.py +244 -0
- memory/instincts.py +128 -0
- memory/observations.py +439 -0
- memory/schema_contract.py +361 -0
- memory/sectors.py +165 -0
- memory/simhash.py +164 -0
- memory/store.py +1383 -0
- memory/temporal.py +173 -0
- memory/vector.py +458 -0
- plugins/__init__.py +0 -0
- plugins/automatisch/__init__.py +5 -0
- plugins/automatisch/automatisch_plugin.py +78 -0
- plugins/brevo/__init__.py +5 -0
- plugins/brevo/brevo_plugin.py +78 -0
- plugins/chatwoot/__init__.py +5 -0
- plugins/chatwoot/chatwoot_plugin.py +78 -0
- plugins/code2video/README.md +87 -0
- plugins/code2video/__init__.py +3 -0
- plugins/code2video/code2video_plugin.py +229 -0
- plugins/code2video/entrypoint.py +166 -0
- plugins/code2video/litellm-c2v.yaml +30 -0
- plugins/context7/__init__.py +5 -0
- plugins/context7/context7_plugin.py +57 -0
- plugins/documenso/__init__.py +5 -0
- plugins/documenso/documenso_plugin.py +78 -0
- plugins/erpnext/__init__.py +5 -0
- plugins/erpnext/erpnext_plugin.py +78 -0
- plugins/fiverr/__init__.py +5 -0
- plugins/fiverr/fiverr_plugin.py +82 -0
- plugins/flagsmith/__init__.py +5 -0
- plugins/flagsmith/flagsmith_plugin.py +78 -0
- plugins/formbricks/__init__.py +5 -0
- plugins/formbricks/formbricks_plugin.py +78 -0
- plugins/freelancer/__init__.py +5 -0
- plugins/freelancer/freelancer_plugin.py +106 -0
- plugins/google_ads/__init__.py +5 -0
- plugins/google_ads/google_ads_plugin.py +78 -0
- plugins/graphify/__init__.py +3 -0
- plugins/graphify/graph_plugin.py +168 -0
- plugins/growthbook/__init__.py +5 -0
- plugins/growthbook/growthbook_plugin.py +78 -0
- plugins/hubspot/__init__.py +5 -0
- plugins/hubspot/hubspot_plugin.py +78 -0
- plugins/khamsat/__init__.py +5 -0
- plugins/khamsat/khamsat_plugin.py +80 -0
- plugins/kit/__init__.py +5 -0
- plugins/kit/kit_plugin.py +78 -0
- plugins/klaviyo/__init__.py +5 -0
- plugins/klaviyo/klaviyo_plugin.py +78 -0
- plugins/lago/__init__.py +5 -0
- plugins/lago/lago_plugin.py +78 -0
- plugins/linkedin/__init__.py +5 -0
- plugins/linkedin/linkedin_plugin.py +188 -0
- plugins/linkedin_ads/__init__.py +5 -0
- plugins/linkedin_ads/linkedin_ads_plugin.py +78 -0
- plugins/listmonk/__init__.py +5 -0
- plugins/listmonk/listmonk_plugin.py +78 -0
- plugins/meta_ads/__init__.py +5 -0
- plugins/meta_ads/meta_ads_plugin.py +78 -0
- plugins/mostaql/__init__.py +5 -0
- plugins/mostaql/mostaql_plugin.py +80 -0
- plugins/n8n/__init__.py +5 -0
- plugins/n8n/n8n_plugin.py +78 -0
- plugins/openreplay/__init__.py +5 -0
- plugins/openreplay/openreplay_plugin.py +78 -0
- plugins/posthog/__init__.py +5 -0
- plugins/posthog/posthog_plugin.py +78 -0
- plugins/postiz/__init__.py +5 -0
- plugins/postiz/postiz_plugin.py +78 -0
- plugins/sendgrid/__init__.py +5 -0
- plugins/sendgrid/sendgrid_plugin.py +78 -0
- plugins/tiktok_ads/__init__.py +5 -0
- plugins/tiktok_ads/tiktok_ads_plugin.py +78 -0
- plugins/twenty/__init__.py +5 -0
- plugins/twenty/twenty_plugin.py +78 -0
- plugins/twitter/__init__.py +5 -0
- plugins/twitter/twitter_plugin.py +78 -0
- plugins/upwork/__init__.py +5 -0
- plugins/upwork/upwork_plugin.py +90 -0
- plugins/youtube/__init__.py +5 -0
- plugins/youtube/youtube_plugin.py +78 -0
- runtime/__init__.py +694 -0
- runtime/_osroot.tar.gz +0 -0
- runtime/abstracts.py +159 -0
- runtime/agent_baseline.py +350 -0
- runtime/agent_circuit_breaker.py +305 -0
- runtime/agent_discovery.py +320 -0
- runtime/agent_gateway.py +448 -0
- runtime/agent_sli.py +435 -0
- runtime/api_session.py +229 -0
- runtime/approval_cache.py +61 -0
- runtime/approval_service.py +352 -0
- runtime/approval_sla.py +341 -0
- runtime/astryx.py +111 -0
- runtime/attribution_model.py +76 -0
- runtime/audit.py +641 -0
- runtime/audit_signing.py +346 -0
- runtime/billing_ledger.py +209 -0
- runtime/blade_template_linter.py +239 -0
- runtime/blast_radius.py +526 -0
- runtime/bootstrap.py +233 -0
- runtime/budget.py +800 -0
- runtime/budget_advanced.py +799 -0
- runtime/budget_escalation.py +220 -0
- runtime/c4_docs.py +200 -0
- runtime/call_plan.py +798 -0
- runtime/chat.py +63 -0
- runtime/ci.py +70 -0
- runtime/codemode/__init__.py +14 -0
- runtime/codemode/_child.py +123 -0
- runtime/codemode/executor.py +311 -0
- runtime/config_ops.py +116 -0
- runtime/context_composition.py +149 -0
- runtime/crm_manager.py +164 -0
- runtime/cross_tool_taint.py +529 -0
- runtime/crypto.py +195 -0
- runtime/curriculum.py +113 -0
- runtime/daemon.py +719 -0
- runtime/db_migration_safety.py +251 -0
- runtime/defensive_injection.py +472 -0
- runtime/deps.py +461 -0
- runtime/design_db.py +505 -0
- runtime/design_library.py +467 -0
- runtime/design_slop_verifier.py +540 -0
- runtime/dns_wire.py +200 -0
- runtime/doc_links.py +151 -0
- runtime/doctor.py +312 -0
- runtime/domain_intel.py +342 -0
- runtime/drip_engine.py +162 -0
- runtime/durable.py +443 -0
- runtime/enforcement.py +282 -0
- runtime/enums.py +67 -0
- runtime/evidence.py +97 -0
- runtime/experiment_tracker.py +184 -0
- runtime/fairness_detector.py +404 -0
- runtime/feature_flags.py +97 -0
- runtime/filament_access_auditor.py +232 -0
- runtime/find.py +150 -0
- runtime/funnel_tracker.py +128 -0
- runtime/gateway_proxy.py +335 -0
- runtime/governance.py +71 -0
- runtime/guardian.py +531 -0
- runtime/guardrails/__init__.py +11 -0
- runtime/guardrails/prompt_injection.py +143 -0
- runtime/hallucination_detector.py +398 -0
- runtime/harness_exporter.py +179 -0
- runtime/heal.py +316 -0
- runtime/hook_lifecycle.py +162 -0
- runtime/hooks.py +183 -0
- runtime/ingest_rules.py +106 -0
- runtime/injection_detector.py +794 -0
- runtime/inventory_note.py +165 -0
- runtime/kernel.py +1299 -0
- runtime/laravel_policy_linter.py +247 -0
- runtime/lead_scorer.py +86 -0
- runtime/lifecycle.py +158 -0
- runtime/llm_attestation.py +511 -0
- runtime/local_responder.py +133 -0
- runtime/loop_detector.py +394 -0
- runtime/managers/__init__.py +15 -0
- runtime/managers/agent_manager.py +106 -0
- runtime/managers/chat_manager.py +93 -0
- runtime/managers/policy_manager.py +353 -0
- runtime/managers/workflow_manager.py +136 -0
- runtime/marketing_compliance.py +74 -0
- runtime/mcp_auditor.py +701 -0
- runtime/mcp_client.py +743 -0
- runtime/mcp_firewall.py +381 -0
- runtime/mcp_freeze.py +114 -0
- runtime/mcp_manifest_lock.py +327 -0
- runtime/mcp_orchestrator.py +224 -0
- runtime/mcp_protocol.py +203 -0
- runtime/metrics.py +566 -0
- runtime/middleware.py +157 -0
- runtime/migrations.py +305 -0
- runtime/model_router.py +314 -0
- runtime/models_catalog.py +92 -0
- runtime/orchestrator.py +109 -0
- runtime/output_filter.py +87 -0
- runtime/overengineering.py +181 -0
- runtime/persona.py +524 -0
- runtime/personas.yaml +2130 -0
- runtime/pipeline_analytics.py +91 -0
- runtime/pipeline_catalog.py +263 -0
- runtime/plan_diff_validator.py +357 -0
- runtime/plugin.py +409 -0
- runtime/plugin_packager.py +158 -0
- runtime/plugin_transactions.py +151 -0
- runtime/policies/agentic-owasp.yaml +76 -0
- runtime/policies/consequence-tiers.yaml +92 -0
- runtime/policies/default.yaml +48 -0
- runtime/policies/examples/api-rate-limits.yaml +13 -0
- runtime/policies/examples/data-exfiltration.yaml +13 -0
- runtime/policies/examples/time-based-access.yaml +13 -0
- runtime/policies/guardian.yaml +101 -0
- runtime/policies/mcp_firewall.yaml +49 -0
- runtime/policies/probity.yaml +74 -0
- runtime/policy.py +558 -0
- runtime/policy_lint.py +255 -0
- runtime/post_queue.py +93 -0
- runtime/preloop.py +65 -0
- runtime/pricing_calculator.py +87 -0
- runtime/probity.py +211 -0
- runtime/project_config.py +111 -0
- runtime/prompt_gate.py +545 -0
- runtime/prompt_injection_detector.py +322 -0
- runtime/repository.py +113 -0
- runtime/rule_compiler.py +180 -0
- runtime/rule_frontmatter.py +180 -0
- runtime/rules_materializer.py +373 -0
- runtime/saga.py +213 -0
- runtime/schemas.py +299 -0
- runtime/secret_scrub.py +101 -0
- runtime/security_scanner.py +612 -0
- runtime/self_healing.py +116 -0
- runtime/sensitive.py +63 -0
- runtime/seo_issue_registry.py +349 -0
- runtime/service_catalog.py +476 -0
- runtime/settings.py +887 -0
- runtime/skill_lifecycle.py +352 -0
- runtime/skill_lint.py +344 -0
- runtime/skill_lock.py +213 -0
- runtime/skill_resolver.py +111 -0
- runtime/skill_scan.py +155 -0
- runtime/skill_validator.py +195 -0
- runtime/sovereign.py +80 -0
- runtime/spec/__init__.py +54 -0
- runtime/spec/analysis.py +280 -0
- runtime/spec/changes.py +179 -0
- runtime/spec/engine.py +660 -0
- runtime/spec/models.py +201 -0
- runtime/spec/review.py +298 -0
- runtime/spec/scaffold.py +131 -0
- runtime/spec/templates.py +80 -0
- runtime/spec_engine.py +61 -0
- runtime/stale_api_detector.py +256 -0
- runtime/storage_backend.py +561 -0
- runtime/supply_chain_guard.py +980 -0
- runtime/taint.py +390 -0
- runtime/task_contract/__init__.py +55 -0
- runtime/task_contract/classifier.py +102 -0
- runtime/task_contract/enforce.py +164 -0
- runtime/task_contract/engine.py +328 -0
- runtime/task_contract/evidence.py +145 -0
- runtime/task_contract/models.py +209 -0
- runtime/task_contract/validation.py +76 -0
- runtime/tech_stack.py +388 -0
- runtime/telemetry.py +162 -0
- runtime/tool_envelope.py +153 -0
- runtime/tool_output_sanitizer.py +171 -0
- runtime/tracing.py +212 -0
- runtime/tracing_otel.py +120 -0
- runtime/ui_a11y_checker.py +288 -0
- runtime/uninstaller.py +572 -0
- runtime/uninstaller_gui.py +498 -0
- runtime/verification.py +121 -0
- 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
|
+
]
|