lambda-watcher 0.1.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.
- lambda_watcher/__init__.py +4 -0
- lambda_watcher/__main__.py +4 -0
- lambda_watcher/analysis/__init__.py +115 -0
- lambda_watcher/analysis/deps.py +291 -0
- lambda_watcher/analysis/envvars.py +80 -0
- lambda_watcher/analysis/handler.py +111 -0
- lambda_watcher/analysis/inventory.py +118 -0
- lambda_watcher/analysis/runtime.py +117 -0
- lambda_watcher/analysis/secrets.py +178 -0
- lambda_watcher/analysis/services.py +76 -0
- lambda_watcher/cli.py +1406 -0
- lambda_watcher/config.py +324 -0
- lambda_watcher/db.py +466 -0
- lambda_watcher/diffing/__init__.py +14 -0
- lambda_watcher/diffing/build.py +51 -0
- lambda_watcher/diffing/compare.py +525 -0
- lambda_watcher/diffing/highlight.py +312 -0
- lambda_watcher/diffing/icons.py +132 -0
- lambda_watcher/diffing/intraline.py +162 -0
- lambda_watcher/diffing/render_html.py +697 -0
- lambda_watcher/diffing/render_text.py +198 -0
- lambda_watcher/extract.py +227 -0
- lambda_watcher/gitmirror.py +151 -0
- lambda_watcher/identify.py +201 -0
- lambda_watcher/ingest.py +480 -0
- lambda_watcher/notify.py +59 -0
- lambda_watcher/reindex.py +158 -0
- lambda_watcher/service.py +553 -0
- lambda_watcher/store.py +209 -0
- lambda_watcher/templates.py +124 -0
- lambda_watcher/utils.py +314 -0
- lambda_watcher/watcher.py +241 -0
- lambda_watcher-0.1.0.dist-info/METADATA +409 -0
- lambda_watcher-0.1.0.dist-info/RECORD +38 -0
- lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
- lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
- lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
- lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Analysis pipeline: turn an extracted package into a structured manifest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..config import AnalysisConfig
|
|
10
|
+
from .deps import Dependency, detect_dependencies
|
|
11
|
+
from .envvars import EnvVarRef, detect_env_vars
|
|
12
|
+
from .handler import HandlerCandidate, detect_handlers
|
|
13
|
+
from .inventory import FileEntry, Inventory, build_inventory
|
|
14
|
+
from .runtime import RuntimeGuess, detect_runtime
|
|
15
|
+
from .secrets import Finding, scan
|
|
16
|
+
from .services import ServiceRef, detect_services
|
|
17
|
+
|
|
18
|
+
MANIFEST_SCHEMA = 1
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Analysis",
|
|
22
|
+
"Dependency",
|
|
23
|
+
"EnvVarRef",
|
|
24
|
+
"FileEntry",
|
|
25
|
+
"Finding",
|
|
26
|
+
"HandlerCandidate",
|
|
27
|
+
"Inventory",
|
|
28
|
+
"RuntimeGuess",
|
|
29
|
+
"ServiceRef",
|
|
30
|
+
"analyse",
|
|
31
|
+
"MANIFEST_SCHEMA",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Analysis:
|
|
37
|
+
"""Everything we learned about one extracted deployment package."""
|
|
38
|
+
|
|
39
|
+
inventory: Inventory
|
|
40
|
+
runtime: RuntimeGuess
|
|
41
|
+
handlers: list[HandlerCandidate] = field(default_factory=list)
|
|
42
|
+
dependencies: list[Dependency] = field(default_factory=list)
|
|
43
|
+
env_vars: list[EnvVarRef] = field(default_factory=list)
|
|
44
|
+
services: list[ServiceRef] = field(default_factory=list)
|
|
45
|
+
findings: list[Finding] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def primary_handler(self) -> str | None:
|
|
49
|
+
return self.handlers[0].handler if self.handlers else None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def vendor_file_count(self) -> int:
|
|
53
|
+
return sum(1 for f in self.inventory.files if f.is_vendor)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def vendor_size(self) -> int:
|
|
57
|
+
return sum(f.size for f in self.inventory.files if f.is_vendor)
|
|
58
|
+
|
|
59
|
+
def unique_env_vars(self, include_reserved: bool = False) -> list[str]:
|
|
60
|
+
names = {
|
|
61
|
+
ref.name for ref in self.env_vars if include_reserved or not ref.is_reserved
|
|
62
|
+
}
|
|
63
|
+
return sorted(names)
|
|
64
|
+
|
|
65
|
+
def unique_services(self) -> list[str]:
|
|
66
|
+
return sorted({ref.service for ref in self.services})
|
|
67
|
+
|
|
68
|
+
def totals(self) -> dict[str, int]:
|
|
69
|
+
return {
|
|
70
|
+
"file_count": self.inventory.file_count,
|
|
71
|
+
"total_size": self.inventory.total_size,
|
|
72
|
+
"code_file_count": self.inventory.code_file_count,
|
|
73
|
+
"code_size": self.inventory.code_size,
|
|
74
|
+
"code_lines": self.inventory.code_lines,
|
|
75
|
+
"vendor_file_count": self.vendor_file_count,
|
|
76
|
+
"vendor_size": self.vendor_size,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def to_manifest(self, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
80
|
+
manifest: dict[str, Any] = {
|
|
81
|
+
"schema": MANIFEST_SCHEMA,
|
|
82
|
+
"tree_hash": self.inventory.tree_hash,
|
|
83
|
+
"runtime": self.runtime.as_dict(),
|
|
84
|
+
"handlers": [h.as_dict() for h in self.handlers],
|
|
85
|
+
"totals": self.totals(),
|
|
86
|
+
"languages": self.inventory.language_breakdown(),
|
|
87
|
+
"dependencies": [d.as_dict() for d in self.dependencies],
|
|
88
|
+
"env_vars": [e.as_dict() for e in self.env_vars],
|
|
89
|
+
"services": [s.as_dict() for s in self.services],
|
|
90
|
+
"findings": [f.as_dict() for f in self.findings],
|
|
91
|
+
"files": [f.as_dict() for f in self.inventory.files],
|
|
92
|
+
}
|
|
93
|
+
if extra:
|
|
94
|
+
manifest.update(extra)
|
|
95
|
+
return manifest
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def analyse(root: Path, cfg: AnalysisConfig) -> Analysis:
|
|
99
|
+
"""Run every analyser over the extracted tree at ``root``."""
|
|
100
|
+
inventory = build_inventory(root, cfg.vendor_globs, cfg.max_scan_file_kb)
|
|
101
|
+
runtime = detect_runtime(inventory)
|
|
102
|
+
handlers = detect_handlers(root, inventory)
|
|
103
|
+
dependencies = detect_dependencies(root, inventory)
|
|
104
|
+
env_vars = detect_env_vars(root, inventory) if cfg.scan_env_vars else []
|
|
105
|
+
services = detect_services(root, inventory) if cfg.scan_aws_services else []
|
|
106
|
+
findings = scan(root, inventory, check_secrets=cfg.scan_secrets)
|
|
107
|
+
return Analysis(
|
|
108
|
+
inventory=inventory,
|
|
109
|
+
runtime=runtime,
|
|
110
|
+
handlers=handlers,
|
|
111
|
+
dependencies=dependencies,
|
|
112
|
+
env_vars=env_vars,
|
|
113
|
+
services=services,
|
|
114
|
+
findings=findings,
|
|
115
|
+
)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Extract dependencies from a deployment package.
|
|
2
|
+
|
|
3
|
+
Two kinds of dependency are collected and kept apart:
|
|
4
|
+
|
|
5
|
+
``declared``
|
|
6
|
+
What a manifest says the function wants (``requirements.txt``,
|
|
7
|
+
``package.json``, ``go.mod`` ...).
|
|
8
|
+
``installed``
|
|
9
|
+
What is actually vendored inside the zip (``*.dist-info/METADATA``,
|
|
10
|
+
``node_modules/*/package.json``). These are the versions that really ran,
|
|
11
|
+
and they are usually the ones worth diffing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
|
|
21
|
+
from ..utils import LOG, read_text
|
|
22
|
+
from .inventory import Inventory
|
|
23
|
+
|
|
24
|
+
try: # Python 3.11+
|
|
25
|
+
import tomllib # type: ignore[import-not-found]
|
|
26
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
27
|
+
try:
|
|
28
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
29
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
30
|
+
tomllib = None # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Dependency:
|
|
35
|
+
manager: str # pip | npm | go | maven | gem
|
|
36
|
+
name: str
|
|
37
|
+
version: str | None
|
|
38
|
+
source: str # the file it came from
|
|
39
|
+
is_declared: bool # False => vendored/installed
|
|
40
|
+
|
|
41
|
+
def key(self) -> tuple[str, str]:
|
|
42
|
+
return (self.manager, self.name.lower())
|
|
43
|
+
|
|
44
|
+
def as_dict(self) -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"manager": self.manager,
|
|
47
|
+
"name": self.name,
|
|
48
|
+
"version": self.version,
|
|
49
|
+
"source": self.source,
|
|
50
|
+
"is_declared": self.is_declared,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_REQ_LINE = re.compile(
|
|
55
|
+
r"^\s*(?P<name>[A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*"
|
|
56
|
+
r"(?P<op>==|>=|<=|~=|!=|>|<|===)?\s*(?P<version>[^\s;#]+)?"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_requirements(text: str, source: str) -> list[Dependency]:
|
|
61
|
+
deps: list[Dependency] = []
|
|
62
|
+
for raw in text.splitlines():
|
|
63
|
+
line = raw.split("#", 1)[0].strip()
|
|
64
|
+
if not line or line.startswith("-"):
|
|
65
|
+
continue # -r includes, -e editables, --index-url
|
|
66
|
+
if line.startswith(("git+", "http://", "https://")):
|
|
67
|
+
name = re.sub(r"[#?].*$", "", line).rstrip("/").split("/")[-1]
|
|
68
|
+
deps.append(Dependency("pip", name or line, None, source, True))
|
|
69
|
+
continue
|
|
70
|
+
if " @ " in line: # PEP 508 direct reference
|
|
71
|
+
name = line.split(" @ ", 1)[0].strip()
|
|
72
|
+
deps.append(Dependency("pip", name, line.split(" @ ", 1)[1].strip(), source, True))
|
|
73
|
+
continue
|
|
74
|
+
match = _REQ_LINE.match(line)
|
|
75
|
+
if not match or not match.group("name"):
|
|
76
|
+
continue
|
|
77
|
+
version = match.group("version") if match.group("op") else None
|
|
78
|
+
deps.append(Dependency("pip", match.group("name"), version, source, True))
|
|
79
|
+
return deps
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_pyproject(text: str, source: str) -> list[Dependency]:
|
|
83
|
+
if tomllib is None:
|
|
84
|
+
return []
|
|
85
|
+
try:
|
|
86
|
+
data = tomllib.loads(text)
|
|
87
|
+
except Exception: # noqa: BLE001 - malformed manifests are not fatal
|
|
88
|
+
return []
|
|
89
|
+
deps: list[Dependency] = []
|
|
90
|
+
for spec in data.get("project", {}).get("dependencies", []) or []:
|
|
91
|
+
if not isinstance(spec, str):
|
|
92
|
+
continue
|
|
93
|
+
match = _REQ_LINE.match(spec)
|
|
94
|
+
if match and match.group("name"):
|
|
95
|
+
deps.append(
|
|
96
|
+
Dependency(
|
|
97
|
+
"pip", match.group("name"),
|
|
98
|
+
match.group("version") if match.group("op") else None, source, True,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
poetry = data.get("tool", {}).get("poetry", {}).get("dependencies", {}) or {}
|
|
102
|
+
for name, spec in poetry.items():
|
|
103
|
+
if name.lower() == "python":
|
|
104
|
+
continue
|
|
105
|
+
version = spec if isinstance(spec, str) else (spec or {}).get("version")
|
|
106
|
+
deps.append(Dependency("pip", name, version, source, True))
|
|
107
|
+
return deps
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_package_json(text: str, source: str, declared: bool = True) -> list[Dependency]:
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(text)
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
return []
|
|
115
|
+
deps: list[Dependency] = []
|
|
116
|
+
if not declared:
|
|
117
|
+
# A vendored node_modules/<pkg>/package.json: the resolved version.
|
|
118
|
+
name = data.get("name")
|
|
119
|
+
version = data.get("version")
|
|
120
|
+
if isinstance(name, str):
|
|
121
|
+
return [Dependency("npm", name, version if isinstance(version, str) else None, source, False)]
|
|
122
|
+
return []
|
|
123
|
+
for section in ("dependencies", "devDependencies", "optionalDependencies"):
|
|
124
|
+
for name, version in (data.get(section) or {}).items():
|
|
125
|
+
deps.append(
|
|
126
|
+
Dependency("npm", name, version if isinstance(version, str) else None, source, True)
|
|
127
|
+
)
|
|
128
|
+
return deps
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_package_lock(text: str, source: str) -> list[Dependency]:
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(text)
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
return []
|
|
136
|
+
deps: list[Dependency] = []
|
|
137
|
+
packages = data.get("packages")
|
|
138
|
+
if isinstance(packages, dict): # lockfile v2 / v3
|
|
139
|
+
for path, meta in packages.items():
|
|
140
|
+
if not path or not isinstance(meta, dict):
|
|
141
|
+
continue
|
|
142
|
+
name = meta.get("name") or path.split("node_modules/")[-1]
|
|
143
|
+
version = meta.get("version")
|
|
144
|
+
if name:
|
|
145
|
+
deps.append(Dependency("npm", name, version, source, False))
|
|
146
|
+
elif isinstance(data.get("dependencies"), dict): # lockfile v1
|
|
147
|
+
for name, meta in data["dependencies"].items():
|
|
148
|
+
version = meta.get("version") if isinstance(meta, dict) else None
|
|
149
|
+
deps.append(Dependency("npm", name, version, source, False))
|
|
150
|
+
return deps
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_YARN_ENTRY = re.compile(r'^"?([^@\s"][^@\s"]*)@[^\n:]*:\s*$\n(?:.*\n)*?\s+version\s+"([^"]+)"', re.MULTILINE)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_yarn_lock(text: str, source: str) -> list[Dependency]:
|
|
157
|
+
deps: list[Dependency] = []
|
|
158
|
+
for match in _YARN_ENTRY.finditer(text):
|
|
159
|
+
deps.append(Dependency("npm", match.group(1), match.group(2), source, False))
|
|
160
|
+
return deps
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_GO_REQUIRE_BLOCK = re.compile(r"require\s*\(([^)]*)\)", re.DOTALL)
|
|
164
|
+
_GO_REQUIRE_LINE = re.compile(r"^\s*([^\s/]+\S*)\s+(v\S+)", re.MULTILINE)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_go_mod(text: str, source: str) -> list[Dependency]:
|
|
168
|
+
deps: list[Dependency] = []
|
|
169
|
+
for block in _GO_REQUIRE_BLOCK.findall(text):
|
|
170
|
+
for name, version in _GO_REQUIRE_LINE.findall(block):
|
|
171
|
+
deps.append(Dependency("go", name, version, source, True))
|
|
172
|
+
for line in text.splitlines():
|
|
173
|
+
line = line.strip()
|
|
174
|
+
if line.startswith("require ") and "(" not in line:
|
|
175
|
+
parts = line.split()
|
|
176
|
+
if len(parts) >= 3:
|
|
177
|
+
deps.append(Dependency("go", parts[1], parts[2], source, True))
|
|
178
|
+
return deps
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_pom(text: str, source: str) -> list[Dependency]:
|
|
182
|
+
deps: list[Dependency] = []
|
|
183
|
+
for block in re.findall(r"<dependency>(.*?)</dependency>", text, re.DOTALL):
|
|
184
|
+
group = re.search(r"<groupId>(.*?)</groupId>", block, re.DOTALL)
|
|
185
|
+
artifact = re.search(r"<artifactId>(.*?)</artifactId>", block, re.DOTALL)
|
|
186
|
+
version = re.search(r"<version>(.*?)</version>", block, re.DOTALL)
|
|
187
|
+
if artifact:
|
|
188
|
+
artifact_id = artifact.group(1).strip()
|
|
189
|
+
name = f"{group.group(1).strip()}:{artifact_id}" if group else artifact_id
|
|
190
|
+
resolved = version.group(1).strip() if version else None
|
|
191
|
+
deps.append(Dependency("maven", name, resolved, source, True))
|
|
192
|
+
return deps
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_GEMFILE_LOCK = re.compile(r"^\s{4}([a-zA-Z0-9_-]+)\s+\(([^)]+)\)", re.MULTILINE)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parse_gemfile_lock(text: str, source: str) -> list[Dependency]:
|
|
199
|
+
return [
|
|
200
|
+
Dependency("gem", name, version, source, False)
|
|
201
|
+
for name, version in _GEMFILE_LOCK.findall(text)
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_metadata(text: str, source: str) -> list[Dependency]:
|
|
206
|
+
"""``*.dist-info/METADATA`` or ``*.egg-info/PKG-INFO``: an installed package."""
|
|
207
|
+
name = version = None
|
|
208
|
+
for line in text.splitlines():
|
|
209
|
+
if line.startswith("Name:") and name is None:
|
|
210
|
+
name = line.split(":", 1)[1].strip()
|
|
211
|
+
elif line.startswith("Version:") and version is None:
|
|
212
|
+
version = line.split(":", 1)[1].strip()
|
|
213
|
+
if name and version:
|
|
214
|
+
break
|
|
215
|
+
if not line.strip(): # headers end at the first blank line
|
|
216
|
+
break
|
|
217
|
+
if name:
|
|
218
|
+
return [Dependency("pip", name, version, source, False)]
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# filename (lowercase) -> parser
|
|
223
|
+
_MANIFEST_PARSERS = {
|
|
224
|
+
"requirements.txt": _parse_requirements,
|
|
225
|
+
"requirements-prod.txt": _parse_requirements,
|
|
226
|
+
"requirements_prod.txt": _parse_requirements,
|
|
227
|
+
"pyproject.toml": _parse_pyproject,
|
|
228
|
+
"package.json": _parse_package_json,
|
|
229
|
+
"package-lock.json": _parse_package_lock,
|
|
230
|
+
"yarn.lock": _parse_yarn_lock,
|
|
231
|
+
"go.mod": _parse_go_mod,
|
|
232
|
+
"pom.xml": _parse_pom,
|
|
233
|
+
"gemfile.lock": _parse_gemfile_lock,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def detect_dependencies(root: Path, inventory: Inventory) -> list[Dependency]:
|
|
238
|
+
"""Collect declared and installed dependencies from an extracted package."""
|
|
239
|
+
found: list[Dependency] = []
|
|
240
|
+
|
|
241
|
+
for entry in inventory.files:
|
|
242
|
+
pure = PurePosixPath(entry.path)
|
|
243
|
+
name = pure.name.lower()
|
|
244
|
+
parent = pure.parent.name.lower()
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
# Installed Python distributions.
|
|
248
|
+
if name == "metadata" and parent.endswith(".dist-info"):
|
|
249
|
+
text = read_text(root / entry.path, max_bytes=64 * 1024)
|
|
250
|
+
if text:
|
|
251
|
+
found.extend(_parse_metadata(text, entry.path))
|
|
252
|
+
continue
|
|
253
|
+
if name == "pkg-info" and parent.endswith(".egg-info"):
|
|
254
|
+
text = read_text(root / entry.path, max_bytes=64 * 1024)
|
|
255
|
+
if text:
|
|
256
|
+
found.extend(_parse_metadata(text, entry.path))
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# Installed npm packages: node_modules/<pkg>/package.json, or
|
|
260
|
+
# node_modules/@scope/<pkg>/package.json for scoped ones.
|
|
261
|
+
if name == "package.json" and "node_modules/" in entry.path:
|
|
262
|
+
tail = entry.path.rsplit("node_modules/", 1)[1]
|
|
263
|
+
max_depth = 2 if tail.startswith("@") else 1
|
|
264
|
+
if tail.count("/") <= max_depth: # skip foo/dist/package.json and friends
|
|
265
|
+
text = read_text(root / entry.path, max_bytes=256 * 1024)
|
|
266
|
+
if text:
|
|
267
|
+
found.extend(_parse_package_json(text, entry.path, declared=False))
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
if entry.is_vendor:
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
parser = _MANIFEST_PARSERS.get(name)
|
|
274
|
+
if parser is None:
|
|
275
|
+
continue
|
|
276
|
+
if entry.size > 8 * 1024 * 1024:
|
|
277
|
+
continue
|
|
278
|
+
text = read_text(root / entry.path, max_bytes=8 * 1024 * 1024)
|
|
279
|
+
if text:
|
|
280
|
+
found.extend(parser(text, entry.path))
|
|
281
|
+
except Exception as exc: # noqa: BLE001 - one bad manifest must not fail ingest
|
|
282
|
+
LOG.debug("dependency parse failed for %s: %s", entry.path, exc)
|
|
283
|
+
|
|
284
|
+
# Deduplicate, preferring installed versions over declared ranges.
|
|
285
|
+
best: dict[tuple[str, str, bool], Dependency] = {}
|
|
286
|
+
for dep in found:
|
|
287
|
+
key = (dep.manager, dep.name.lower(), dep.is_declared)
|
|
288
|
+
current = best.get(key)
|
|
289
|
+
if current is None or (current.version is None and dep.version is not None):
|
|
290
|
+
best[key] = dep
|
|
291
|
+
return sorted(best.values(), key=lambda d: (d.manager, d.is_declared, d.name.lower()))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Collect the environment variables the code reads.
|
|
2
|
+
|
|
3
|
+
Environment variables are configuration that lives outside the zip, so a diff
|
|
4
|
+
that shows a new ``os.environ["TABLE_NAME"]`` is a strong signal that the
|
|
5
|
+
deployment also needs a config change.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..utils import read_text
|
|
15
|
+
from .inventory import Inventory
|
|
16
|
+
|
|
17
|
+
_PATTERNS = [
|
|
18
|
+
re.compile(r"""os\.environ\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]"""),
|
|
19
|
+
re.compile(r"""os\.environ\.get\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]"""),
|
|
20
|
+
re.compile(r"""os\.getenv\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]"""),
|
|
21
|
+
re.compile(r"""process\.env\.([A-Za-z_][A-Za-z0-9_]*)"""),
|
|
22
|
+
re.compile(r"""process\.env\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]"""),
|
|
23
|
+
re.compile(r"""System\.getenv\s*\(\s*"([A-Za-z_][A-Za-z0-9_]*)"""),
|
|
24
|
+
re.compile(r"""ENV\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]"""),
|
|
25
|
+
re.compile(r"""Environment\.GetEnvironmentVariable\s*\(\s*"([A-Za-z_][A-Za-z0-9_]*)"""),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Runtime-provided variables are noise in a diff.
|
|
29
|
+
_AWS_RESERVED = {
|
|
30
|
+
"AWS_REGION", "AWS_DEFAULT_REGION", "AWS_EXECUTION_ENV", "AWS_LAMBDA_FUNCTION_NAME",
|
|
31
|
+
"AWS_LAMBDA_FUNCTION_VERSION", "AWS_LAMBDA_FUNCTION_MEMORY_SIZE", "AWS_LAMBDA_LOG_GROUP_NAME",
|
|
32
|
+
"AWS_LAMBDA_LOG_STREAM_NAME", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY",
|
|
33
|
+
"AWS_SESSION_TOKEN", "LAMBDA_TASK_ROOT", "LAMBDA_RUNTIME_DIR", "_HANDLER", "_X_AMZN_TRACE_ID",
|
|
34
|
+
"TZ", "PATH", "HOME", "NODE_PATH", "PYTHONPATH", "LANG",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_SCANNABLE = {"python", "javascript", "typescript", "java", "ruby", "csharp", "go", "shell"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EnvVarRef:
|
|
42
|
+
name: str
|
|
43
|
+
path: str
|
|
44
|
+
line: int
|
|
45
|
+
is_reserved: bool = False
|
|
46
|
+
|
|
47
|
+
def as_dict(self) -> dict:
|
|
48
|
+
return {"name": self.name, "path": self.path, "line": self.line, "is_reserved": self.is_reserved}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_env_vars(
|
|
52
|
+
root: Path, inventory: Inventory, include_vendor: bool = False, max_files: int = 2000
|
|
53
|
+
) -> list[EnvVarRef]:
|
|
54
|
+
refs: list[EnvVarRef] = []
|
|
55
|
+
seen: set[tuple[str, str, int]] = set()
|
|
56
|
+
entries = inventory.files if include_vendor else inventory.code_files
|
|
57
|
+
scanned = 0
|
|
58
|
+
|
|
59
|
+
for entry in entries:
|
|
60
|
+
if scanned >= max_files:
|
|
61
|
+
break
|
|
62
|
+
if not entry.is_text or entry.lang not in _SCANNABLE:
|
|
63
|
+
continue
|
|
64
|
+
text = read_text(root / entry.path, max_bytes=1024 * 1024)
|
|
65
|
+
if not text:
|
|
66
|
+
continue
|
|
67
|
+
scanned += 1
|
|
68
|
+
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
69
|
+
for pattern in _PATTERNS:
|
|
70
|
+
for match in pattern.finditer(line):
|
|
71
|
+
name = match.group(1)
|
|
72
|
+
key = (name, entry.path, line_no)
|
|
73
|
+
if key in seen:
|
|
74
|
+
continue
|
|
75
|
+
seen.add(key)
|
|
76
|
+
refs.append(
|
|
77
|
+
EnvVarRef(name, entry.path, line_no, is_reserved=name in _AWS_RESERVED)
|
|
78
|
+
)
|
|
79
|
+
refs.sort(key=lambda r: (r.name, r.path, r.line))
|
|
80
|
+
return refs
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Find the likely Lambda entry point(s) in an extracted package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
from ..utils import read_text
|
|
10
|
+
from .inventory import Inventory
|
|
11
|
+
|
|
12
|
+
# def handler(event, context) / async def handler(event, context)
|
|
13
|
+
_PY_HANDLER = re.compile(
|
|
14
|
+
r"^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(\s*[A-Za-z_]\w*\s*(?::[^,)]+)?\s*,"
|
|
15
|
+
r"\s*[A-Za-z_]\w*",
|
|
16
|
+
re.MULTILINE,
|
|
17
|
+
)
|
|
18
|
+
_JS_HANDLER = re.compile(
|
|
19
|
+
r"(?:^|\n)\s*(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=|"
|
|
20
|
+
r"(?:^|\n)\s*export\s+(?:async\s+)?(?:const|function)\s+([A-Za-z_$][\w$]*)",
|
|
21
|
+
)
|
|
22
|
+
_JAVA_HANDLER = re.compile(r"implements\s+RequestHandler|public\s+\w+\s+handleRequest\s*\(")
|
|
23
|
+
|
|
24
|
+
# Filenames AWS itself defaults to, in preference order.
|
|
25
|
+
_PREFERRED = (
|
|
26
|
+
"lambda_function.py", "index.mjs", "index.js", "index.py", "app.py",
|
|
27
|
+
"main.py", "handler.py", "lambda_handler.py", "app.js", "handler.js",
|
|
28
|
+
"index.cjs", "main.go", "bootstrap",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class HandlerCandidate:
|
|
34
|
+
path: str
|
|
35
|
+
symbol: str
|
|
36
|
+
handler: str # "module.function", the value you paste into the console
|
|
37
|
+
score: int
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict:
|
|
40
|
+
return {"path": self.path, "symbol": self.symbol, "handler": self.handler, "score": self.score}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _module_name(path: str) -> str:
|
|
44
|
+
pure = PurePosixPath(path)
|
|
45
|
+
stem = pure.stem
|
|
46
|
+
parts = list(pure.parts[:-1]) + [stem]
|
|
47
|
+
return ".".join(parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def detect_handlers(root: Path, inventory: Inventory, max_files: int = 400) -> list[HandlerCandidate]:
|
|
51
|
+
"""Return handler candidates, best guess first."""
|
|
52
|
+
candidates: list[HandlerCandidate] = []
|
|
53
|
+
scanned = 0
|
|
54
|
+
|
|
55
|
+
entries = [
|
|
56
|
+
f
|
|
57
|
+
for f in inventory.code_files
|
|
58
|
+
if f.is_text and f.lang in {"python", "javascript", "typescript", "java"}
|
|
59
|
+
]
|
|
60
|
+
# Shallow files first: the real handler is rarely six directories deep.
|
|
61
|
+
entries.sort(key=lambda f: (f.path.count("/"), len(f.path)))
|
|
62
|
+
|
|
63
|
+
for entry in entries:
|
|
64
|
+
if scanned >= max_files:
|
|
65
|
+
break
|
|
66
|
+
scanned += 1
|
|
67
|
+
text = read_text(root / entry.path, max_bytes=512 * 1024)
|
|
68
|
+
if not text:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
name = PurePosixPath(entry.path).name
|
|
72
|
+
base_score = 0
|
|
73
|
+
if name in _PREFERRED:
|
|
74
|
+
base_score += 50 - _PREFERRED.index(name)
|
|
75
|
+
if entry.path.count("/") == 0:
|
|
76
|
+
base_score += 20
|
|
77
|
+
|
|
78
|
+
if entry.lang == "python":
|
|
79
|
+
for match in _PY_HANDLER.finditer(text):
|
|
80
|
+
symbol = match.group(1)
|
|
81
|
+
score = base_score + (30 if symbol in {"lambda_handler", "handler"} else 0)
|
|
82
|
+
if symbol.startswith("_"):
|
|
83
|
+
score -= 15
|
|
84
|
+
candidates.append(
|
|
85
|
+
HandlerCandidate(entry.path, symbol, f"{_module_name(entry.path)}.{symbol}", score)
|
|
86
|
+
)
|
|
87
|
+
elif entry.lang in {"javascript", "typescript"}:
|
|
88
|
+
for match in _JS_HANDLER.finditer(text):
|
|
89
|
+
symbol = match.group(1) or match.group(2)
|
|
90
|
+
if not symbol:
|
|
91
|
+
continue
|
|
92
|
+
score = base_score + (30 if symbol == "handler" else 0)
|
|
93
|
+
candidates.append(
|
|
94
|
+
HandlerCandidate(entry.path, symbol, f"{_module_name(entry.path)}.{symbol}", score)
|
|
95
|
+
)
|
|
96
|
+
elif entry.lang == "java":
|
|
97
|
+
if _JAVA_HANDLER.search(text):
|
|
98
|
+
candidates.append(
|
|
99
|
+
HandlerCandidate(entry.path, "handleRequest", _module_name(entry.path), base_score + 20)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
candidates.sort(key=lambda c: -c.score)
|
|
103
|
+
# Deduplicate on the handler string while preserving order.
|
|
104
|
+
seen: set[str] = set()
|
|
105
|
+
unique: list[HandlerCandidate] = []
|
|
106
|
+
for candidate in candidates:
|
|
107
|
+
if candidate.handler in seen:
|
|
108
|
+
continue
|
|
109
|
+
seen.add(candidate.handler)
|
|
110
|
+
unique.append(candidate)
|
|
111
|
+
return unique[:10]
|