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,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
|