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.
Files changed (38) hide show
  1. lambda_watcher/__init__.py +4 -0
  2. lambda_watcher/__main__.py +4 -0
  3. lambda_watcher/analysis/__init__.py +115 -0
  4. lambda_watcher/analysis/deps.py +291 -0
  5. lambda_watcher/analysis/envvars.py +80 -0
  6. lambda_watcher/analysis/handler.py +111 -0
  7. lambda_watcher/analysis/inventory.py +118 -0
  8. lambda_watcher/analysis/runtime.py +117 -0
  9. lambda_watcher/analysis/secrets.py +178 -0
  10. lambda_watcher/analysis/services.py +76 -0
  11. lambda_watcher/cli.py +1406 -0
  12. lambda_watcher/config.py +324 -0
  13. lambda_watcher/db.py +466 -0
  14. lambda_watcher/diffing/__init__.py +14 -0
  15. lambda_watcher/diffing/build.py +51 -0
  16. lambda_watcher/diffing/compare.py +525 -0
  17. lambda_watcher/diffing/highlight.py +312 -0
  18. lambda_watcher/diffing/icons.py +132 -0
  19. lambda_watcher/diffing/intraline.py +162 -0
  20. lambda_watcher/diffing/render_html.py +697 -0
  21. lambda_watcher/diffing/render_text.py +198 -0
  22. lambda_watcher/extract.py +227 -0
  23. lambda_watcher/gitmirror.py +151 -0
  24. lambda_watcher/identify.py +201 -0
  25. lambda_watcher/ingest.py +480 -0
  26. lambda_watcher/notify.py +59 -0
  27. lambda_watcher/reindex.py +158 -0
  28. lambda_watcher/service.py +553 -0
  29. lambda_watcher/store.py +209 -0
  30. lambda_watcher/templates.py +124 -0
  31. lambda_watcher/utils.py +314 -0
  32. lambda_watcher/watcher.py +241 -0
  33. lambda_watcher-0.1.0.dist-info/METADATA +409 -0
  34. lambda_watcher-0.1.0.dist-info/RECORD +38 -0
  35. lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
  36. lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
  37. lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,118 @@
1
+ """Walk an extracted package and record every file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from ..utils import count_lines, is_probably_text, language_for, matches_any, sha256_file, tree_hash
9
+
10
+
11
+ @dataclass
12
+ class FileEntry:
13
+ path: str # posix relative path
14
+ size: int
15
+ sha256: str
16
+ mode: int
17
+ is_text: bool
18
+ is_vendor: bool
19
+ lang: str
20
+ lines: int
21
+
22
+ def as_dict(self) -> dict:
23
+ return {
24
+ "path": self.path,
25
+ "size": self.size,
26
+ "sha256": self.sha256,
27
+ "mode": self.mode,
28
+ "is_text": self.is_text,
29
+ "is_vendor": self.is_vendor,
30
+ "lang": self.lang,
31
+ "lines": self.lines,
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class Inventory:
37
+ files: list[FileEntry] = field(default_factory=list)
38
+ tree_hash: str = ""
39
+ total_size: int = 0
40
+ code_size: int = 0
41
+ code_lines: int = 0
42
+
43
+ @property
44
+ def file_count(self) -> int:
45
+ return len(self.files)
46
+
47
+ @property
48
+ def code_files(self) -> list[FileEntry]:
49
+ """First-party files: everything that is not vendored."""
50
+ return [f for f in self.files if not f.is_vendor]
51
+
52
+ @property
53
+ def code_file_count(self) -> int:
54
+ return len(self.code_files)
55
+
56
+ def by_path(self) -> dict[str, FileEntry]:
57
+ return {f.path: f for f in self.files}
58
+
59
+ def language_breakdown(self) -> dict[str, int]:
60
+ counts: dict[str, int] = {}
61
+ for f in self.code_files:
62
+ counts[f.lang] = counts.get(f.lang, 0) + 1
63
+ return dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
64
+
65
+
66
+ def build_inventory(
67
+ root: Path,
68
+ vendor_globs: list[str],
69
+ max_scan_file_kb: int = 2048,
70
+ ) -> Inventory:
71
+ """Hash and classify every file under ``root``."""
72
+ inventory = Inventory()
73
+ hashes: list[tuple[str, str]] = []
74
+ max_scan_bytes = max_scan_file_kb * 1024
75
+
76
+ for path in sorted(root.rglob("*")):
77
+ if path.is_dir():
78
+ continue
79
+ if path.is_symlink() and not path.exists():
80
+ continue
81
+ try:
82
+ stat_result = path.stat()
83
+ except OSError:
84
+ continue
85
+
86
+ rel = path.relative_to(root).as_posix()
87
+ digest = sha256_file(path)
88
+ is_vendor = matches_any(rel, vendor_globs)
89
+ lang = language_for(rel)
90
+ size = stat_result.st_size
91
+
92
+ # Only sniff and count lines for files we might actually diff.
93
+ text = False
94
+ lines = 0
95
+ if size <= max_scan_bytes and lang != "binary":
96
+ text = is_probably_text(path)
97
+ if text:
98
+ lines = count_lines(path)
99
+
100
+ entry = FileEntry(
101
+ path=rel,
102
+ size=size,
103
+ sha256=digest,
104
+ mode=stat_result.st_mode & 0o777,
105
+ is_text=text,
106
+ is_vendor=is_vendor,
107
+ lang=lang,
108
+ lines=lines,
109
+ )
110
+ inventory.files.append(entry)
111
+ hashes.append((rel, digest))
112
+ inventory.total_size += size
113
+ if not is_vendor:
114
+ inventory.code_size += size
115
+ inventory.code_lines += lines
116
+
117
+ inventory.tree_hash = tree_hash(hashes)
118
+ return inventory
@@ -0,0 +1,117 @@
1
+ """Guess the Lambda runtime from the contents of the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import PurePosixPath
7
+
8
+ from .inventory import Inventory
9
+
10
+
11
+ @dataclass
12
+ class RuntimeGuess:
13
+ runtime: str = "unknown"
14
+ confidence: str = "low"
15
+ evidence: list[str] = field(default_factory=list)
16
+ all_scores: dict[str, int] = field(default_factory=dict)
17
+
18
+ def as_dict(self) -> dict:
19
+ return {
20
+ "runtime": self.runtime,
21
+ "confidence": self.confidence,
22
+ "evidence": self.evidence,
23
+ "scores": self.all_scores,
24
+ }
25
+
26
+
27
+ # Strong signals: a specific filename at any depth.
28
+ _MARKER_FILES: dict[str, tuple[str, int]] = {
29
+ "lambda_function.py": ("python", 40),
30
+ "requirements.txt": ("python", 15),
31
+ "pyproject.toml": ("python", 10),
32
+ "package.json": ("nodejs", 20),
33
+ "package-lock.json": ("nodejs", 10),
34
+ "yarn.lock": ("nodejs", 8),
35
+ "index.js": ("nodejs", 25),
36
+ "index.mjs": ("nodejs", 30),
37
+ "app.js": ("nodejs", 12),
38
+ "pom.xml": ("java", 20),
39
+ "build.gradle": ("java", 15),
40
+ "go.mod": ("go", 30),
41
+ "bootstrap": ("provided", 25),
42
+ "gemfile": ("ruby", 20),
43
+ "gemfile.lock": ("ruby", 15),
44
+ }
45
+
46
+ _EXT_SCORES: dict[str, tuple[str, int]] = {
47
+ ".py": ("python", 3),
48
+ ".js": ("nodejs", 3),
49
+ ".mjs": ("nodejs", 3),
50
+ ".cjs": ("nodejs", 3),
51
+ ".ts": ("nodejs", 2),
52
+ ".java": ("java", 3),
53
+ ".class": ("java", 2),
54
+ ".jar": ("java", 6),
55
+ ".go": ("go", 3),
56
+ ".rb": ("ruby", 3),
57
+ ".cs": ("dotnet", 3),
58
+ ".dll": ("dotnet", 4),
59
+ }
60
+
61
+
62
+ def detect_runtime(inventory: Inventory) -> RuntimeGuess:
63
+ scores: dict[str, int] = {}
64
+ evidence: list[str] = []
65
+
66
+ def bump(lang: str, points: int, why: str) -> None:
67
+ scores[lang] = scores.get(lang, 0) + points
68
+ if why not in evidence:
69
+ evidence.append(why)
70
+
71
+ for entry in inventory.files:
72
+ name = PurePosixPath(entry.path).name.lower()
73
+ depth = entry.path.count("/")
74
+
75
+ marker = _MARKER_FILES.get(name)
76
+ if marker:
77
+ lang, points = marker
78
+ # A marker at the package root is far more meaningful than one
79
+ # buried inside a vendored dependency.
80
+ if entry.is_vendor:
81
+ points = max(1, points // 10)
82
+ elif depth > 1:
83
+ points = max(2, points // 3)
84
+ bump(lang, points, f"{entry.path}")
85
+
86
+ if entry.is_vendor:
87
+ continue
88
+ ext = PurePosixPath(entry.path).suffix.lower()
89
+ ext_hit = _EXT_SCORES.get(ext)
90
+ if ext_hit:
91
+ lang, points = ext_hit
92
+ bump(lang, points, f"*{ext}")
93
+
94
+ # A .NET deployment is recognisable by its runtime config file.
95
+ for entry in inventory.files:
96
+ if entry.path.endswith(".runtimeconfig.json") or entry.path.endswith(".deps.json"):
97
+ bump("dotnet", 25, entry.path)
98
+
99
+ if not scores:
100
+ return RuntimeGuess()
101
+
102
+ ranked = sorted(scores.items(), key=lambda kv: -kv[1])
103
+ top, top_score = ranked[0]
104
+ runner_up = ranked[1][1] if len(ranked) > 1 else 0
105
+ if top_score >= 30 and top_score >= runner_up * 2:
106
+ confidence = "high"
107
+ elif top_score >= 10:
108
+ confidence = "medium"
109
+ else:
110
+ confidence = "low"
111
+
112
+ return RuntimeGuess(
113
+ runtime=top,
114
+ confidence=confidence,
115
+ evidence=evidence[:12],
116
+ all_scores=dict(ranked),
117
+ )
@@ -0,0 +1,178 @@
1
+ """Flag credentials and risky calls that shouldn't be sitting in a zip.
2
+
3
+ This is a lightweight tripwire, not a security scanner. It exists because a
4
+ deployment package is exactly the place a hardcoded key survives unnoticed, and
5
+ because "a new AWS key appeared between v7 and v8" is worth seeing in a diff.
6
+ Matched values are stored redacted; the secret itself is never written to the
7
+ index.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from ..utils import read_text
18
+ from .inventory import Inventory
19
+
20
+
21
+ @dataclass
22
+ class Finding:
23
+ kind: str
24
+ severity: str # high | medium | low
25
+ path: str
26
+ line: int
27
+ detail: str
28
+ is_vendor: bool = False
29
+
30
+ def as_dict(self) -> dict:
31
+ return {
32
+ "kind": self.kind,
33
+ "severity": self.severity,
34
+ "path": self.path,
35
+ "line": self.line,
36
+ "detail": self.detail,
37
+ "is_vendor": self.is_vendor,
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Rule:
43
+ kind: str
44
+ severity: str
45
+ pattern: re.Pattern[str]
46
+ group: int = 0
47
+
48
+
49
+ SECRET_RULES: list[Rule] = [
50
+ Rule("aws-access-key-id", "high", re.compile(r"\b((?:AKIA|ASIA|AIDA|AROA)[0-9A-Z]{16})\b"), 1),
51
+ Rule(
52
+ "aws-secret-access-key", "high",
53
+ re.compile(r"""(?i)aws_?secret_?access_?key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?"""), 1,
54
+ ),
55
+ Rule(
56
+ "private-key", "high",
57
+ re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"),
58
+ ),
59
+ Rule("github-token", "high", re.compile(r"\b((?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36})\b"), 1),
60
+ Rule("slack-token", "high", re.compile(r"\b(xox[baprs]-[A-Za-z0-9-]{10,})"), 1),
61
+ Rule("stripe-key", "high", re.compile(r"\b((?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,})\b"), 1),
62
+ Rule("google-api-key", "high", re.compile(r"\b(AIza[0-9A-Za-z_-]{35})\b"), 1),
63
+ Rule(
64
+ "jwt", "medium",
65
+ re.compile(r"\b(eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,})"), 1,
66
+ ),
67
+ Rule(
68
+ "connection-string", "high",
69
+ re.compile(
70
+ r"\b((?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp)"
71
+ r"://[^\s'\"]+:[^\s'\"@]+@[^\s'\"]+)"
72
+ ), 1,
73
+ ),
74
+ Rule(
75
+ "hardcoded-credential", "medium",
76
+ re.compile(
77
+ r"""(?i)\b(?:password|passwd|pwd|secret|api_?key|apikey|auth_?token|access_?token|client_?secret)\b"""
78
+ r"""\s*[=:]\s*['"]([^'"\n]{8,})['"]"""
79
+ ), 1,
80
+ ),
81
+ ]
82
+
83
+ RISK_RULES: list[Rule] = [
84
+ Rule("dynamic-exec", "medium", re.compile(r"\b(?:eval|exec)\s*\(")),
85
+ Rule("shell-injection-risk", "medium", re.compile(r"shell\s*=\s*True|child_process\.exec\s*\(")),
86
+ Rule("pickle-load", "medium", re.compile(r"\bpickle\.loads?\s*\(")),
87
+ Rule("tls-verification-off", "high", re.compile(r"verify\s*=\s*False|rejectUnauthorized\s*:\s*false")),
88
+ Rule("debug-flag", "low", re.compile(r"(?i)\bDEBUG\s*=\s*True\b")),
89
+ ]
90
+
91
+ # Values that are obviously placeholders rather than live credentials.
92
+ _PLACEHOLDER = re.compile(
93
+ r"(?i)^(?:x{3,}|\*{3,}|\.{3,}|<[^>]*>|\{\{.*\}\}|\$\{.*\}|%s|none|null|todo|"
94
+ r"change[_-]?me|your[_-].*|my[_-]?(?:secret|password|key).*|example.*|dummy.*|"
95
+ r"test[_-]?(?:key|secret|token|password)?|sample.*|placeholder.*|redacted.*|"
96
+ r"fake.*|insert.*|replace.*|password|secret|123456\d*|abc123)$"
97
+ )
98
+
99
+ _SCANNABLE = {
100
+ "python", "javascript", "typescript", "java", "ruby", "csharp", "go", "shell",
101
+ "json", "yaml", "toml", "ini", "dotenv", "text", "xml", "powershell",
102
+ }
103
+
104
+
105
+ def _shannon_entropy(value: str) -> float:
106
+ if not value:
107
+ return 0.0
108
+ counts: dict[str, int] = {}
109
+ for char in value:
110
+ counts[char] = counts.get(char, 0) + 1
111
+ length = len(value)
112
+ return -sum((c / length) * math.log2(c / length) for c in counts.values())
113
+
114
+
115
+ def _redact(value: str) -> str:
116
+ value = value.strip()
117
+ if len(value) <= 8:
118
+ return "*" * len(value)
119
+ return f"{value[:4]}…{value[-2:]} ({len(value)} chars)"
120
+
121
+
122
+ def _is_placeholder(value: str) -> bool:
123
+ stripped = value.strip()
124
+ if not stripped or _PLACEHOLDER.match(stripped):
125
+ return True
126
+ if stripped.startswith(("os.environ", "process.env", "${", "{{", "$(")):
127
+ return True
128
+ # Low-entropy strings are usually words, not keys.
129
+ return len(stripped) >= 16 and _shannon_entropy(stripped) < 2.5
130
+
131
+
132
+ def scan(
133
+ root: Path,
134
+ inventory: Inventory,
135
+ include_vendor: bool = False,
136
+ check_secrets: bool = True,
137
+ max_files: int = 3000,
138
+ ) -> list[Finding]:
139
+ findings: list[Finding] = []
140
+ entries = inventory.files if include_vendor else inventory.code_files
141
+ rules = (SECRET_RULES if check_secrets else []) + RISK_RULES
142
+ scanned = 0
143
+
144
+ for entry in entries:
145
+ if scanned >= max_files:
146
+ break
147
+ if not entry.is_text or entry.lang not in _SCANNABLE:
148
+ continue
149
+ if entry.size > 2 * 1024 * 1024:
150
+ continue
151
+ text = read_text(root / entry.path, max_bytes=2 * 1024 * 1024)
152
+ if not text:
153
+ continue
154
+ scanned += 1
155
+
156
+ seen_in_file: set[tuple[str, str]] = set()
157
+ for line_no, line in enumerate(text.splitlines(), start=1):
158
+ if len(line) > 4000: # minified bundles produce nothing but noise
159
+ continue
160
+ for rule in rules:
161
+ match = rule.pattern.search(line)
162
+ if not match:
163
+ continue
164
+ value = match.group(rule.group) if rule.group else match.group(0)
165
+ if rule in SECRET_RULES and rule.group and _is_placeholder(value):
166
+ continue
167
+ detail = _redact(value) if rule.group else value.strip()[:80]
168
+ key = (rule.kind, detail)
169
+ if key in seen_in_file:
170
+ continue
171
+ seen_in_file.add(key)
172
+ findings.append(
173
+ Finding(rule.kind, rule.severity, entry.path, line_no, detail, entry.is_vendor)
174
+ )
175
+
176
+ order = {"high": 0, "medium": 1, "low": 2}
177
+ findings.sort(key=lambda f: (order.get(f.severity, 3), f.path, f.line))
178
+ return findings
@@ -0,0 +1,76 @@
1
+ """Detect which AWS services the package talks to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..utils import read_text
10
+ from .inventory import Inventory
11
+
12
+ _PATTERNS = [
13
+ # boto3.client("dynamodb") / boto3.resource('s3')
14
+ re.compile(r"""boto3\.(?:client|resource)\s*\(\s*['"]([a-z0-9-]+)['"]"""),
15
+ re.compile(r"""session\.(?:client|resource)\s*\(\s*['"]([a-z0-9-]+)['"]"""),
16
+ # @aws-sdk/client-dynamodb
17
+ re.compile(r"""['"]@aws-sdk/client-([a-z0-9-]+)['"]"""),
18
+ # require('aws-sdk').S3 / new AWS.DynamoDB(
19
+ re.compile(r"""new\s+AWS\.([A-Za-z0-9]+)\s*\("""),
20
+ # software.amazon.awssdk.services.s3
21
+ re.compile(r"""software\.amazon\.awssdk\.services\.([a-z0-9]+)"""),
22
+ re.compile(r"""com\.amazonaws\.services\.([a-z0-9]+)"""),
23
+ ]
24
+
25
+ _SCANNABLE = {"python", "javascript", "typescript", "java", "ruby", "csharp", "go"}
26
+
27
+ # Normalise the different spellings onto one service id.
28
+ _ALIASES = {
29
+ "dynamodbdocument": "dynamodb",
30
+ "dynamodbstreams": "dynamodb-streams",
31
+ "secretsmanager": "secretsmanager",
32
+ "ssm": "ssm",
33
+ "sfn": "stepfunctions",
34
+ "states": "stepfunctions",
35
+ "cloudwatchlogs": "logs",
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class ServiceRef:
41
+ service: str
42
+ path: str
43
+ line: int
44
+
45
+ def as_dict(self) -> dict:
46
+ return {"service": self.service, "path": self.path, "line": self.line}
47
+
48
+
49
+ def detect_services(
50
+ root: Path, inventory: Inventory, max_files: int = 2000
51
+ ) -> list[ServiceRef]:
52
+ refs: list[ServiceRef] = []
53
+ seen: set[tuple[str, str]] = set()
54
+ scanned = 0
55
+
56
+ for entry in inventory.code_files:
57
+ if scanned >= max_files:
58
+ break
59
+ if not entry.is_text or entry.lang not in _SCANNABLE:
60
+ continue
61
+ text = read_text(root / entry.path, max_bytes=1024 * 1024)
62
+ if not text:
63
+ continue
64
+ scanned += 1
65
+ for line_no, line in enumerate(text.splitlines(), start=1):
66
+ for pattern in _PATTERNS:
67
+ for match in pattern.finditer(line):
68
+ service = match.group(1).lower()
69
+ service = _ALIASES.get(service, service)
70
+ key = (service, entry.path)
71
+ if key in seen:
72
+ continue
73
+ seen.add(key)
74
+ refs.append(ServiceRef(service, entry.path, line_no))
75
+ refs.sort(key=lambda r: (r.service, r.path))
76
+ return refs