lambda-watcher 0.1.0__py3-none-any.whl → 0.2.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 +1 -1
- lambda_watcher/analysis/__init__.py +42 -0
- lambda_watcher/analysis/deps.py +77 -0
- lambda_watcher/analysis/envvars.py +25 -0
- lambda_watcher/analysis/handler.py +14 -0
- lambda_watcher/analysis/inventory.py +30 -0
- lambda_watcher/analysis/runtime.py +28 -0
- lambda_watcher/analysis/secrets.py +59 -0
- lambda_watcher/analysis/services.py +20 -0
- lambda_watcher/cli.py +200 -28
- lambda_watcher/config.py +81 -11
- lambda_watcher/db.py +152 -2
- lambda_watcher/diffing/compare.py +604 -75
- lambda_watcher/diffing/highlight.py +24 -2
- lambda_watcher/diffing/intraline.py +181 -4
- lambda_watcher/diffing/render_html.py +237 -20
- lambda_watcher/diffing/render_text.py +192 -12
- lambda_watcher/extract.py +19 -0
- lambda_watcher/gitmirror.py +105 -6
- lambda_watcher/identify.py +16 -2
- lambda_watcher/ingest.py +51 -0
- lambda_watcher/notify.py +11 -0
- lambda_watcher/reindex.py +17 -0
- lambda_watcher/service.py +327 -36
- lambda_watcher/store.py +51 -0
- lambda_watcher/templates.py +46 -1
- lambda_watcher/utils.py +73 -0
- lambda_watcher/watcher.py +93 -6
- {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/METADATA +36 -21
- lambda_watcher-0.2.0.dist-info/RECORD +38 -0
- lambda_watcher-0.1.0.dist-info/RECORD +0 -38
- {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/WHEEL +0 -0
- {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/entry_points.txt +0 -0
- {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/top_level.txt +0 -0
lambda_watcher/__init__.py
CHANGED
|
@@ -46,26 +46,57 @@ class Analysis:
|
|
|
46
46
|
|
|
47
47
|
@property
|
|
48
48
|
def primary_handler(self) -> str | None:
|
|
49
|
+
"""The handler AWS would most likely invoke, or None if none was found.
|
|
50
|
+
|
|
51
|
+
:func:`~.handler.detect_handlers` returns its candidates best-first, so this
|
|
52
|
+
is simply the top one — something like ``lambda_function.lambda_handler``.
|
|
53
|
+
"""
|
|
49
54
|
return self.handlers[0].handler if self.handlers else None
|
|
50
55
|
|
|
51
56
|
@property
|
|
52
57
|
def vendor_file_count(self) -> int:
|
|
58
|
+
"""How many files came from ``node_modules``, ``site-packages`` and friends.
|
|
59
|
+
|
|
60
|
+
Usually most of the package. Reported separately so a diff can say "1 file
|
|
61
|
+
you wrote changed, 4,812 vendored files came along with it" instead of
|
|
62
|
+
burying the first number in the second.
|
|
63
|
+
"""
|
|
53
64
|
return sum(1 for f in self.inventory.files if f.is_vendor)
|
|
54
65
|
|
|
55
66
|
@property
|
|
56
67
|
def vendor_size(self) -> int:
|
|
68
|
+
"""Total bytes of vendored files, the companion to :attr:`vendor_file_count`."""
|
|
57
69
|
return sum(f.size for f in self.inventory.files if f.is_vendor)
|
|
58
70
|
|
|
59
71
|
def unique_env_vars(self, include_reserved: bool = False) -> list[str]:
|
|
72
|
+
"""The distinct environment variable names the code reads, sorted.
|
|
73
|
+
|
|
74
|
+
Collapses the per-reference list, which can name the same variable from a
|
|
75
|
+
dozen lines. Runtime-provided names (``AWS_REGION``, ``PATH``) are left out
|
|
76
|
+
unless ``include_reserved`` is set, because they are the same in every
|
|
77
|
+
package and only ever add noise to a diff.
|
|
78
|
+
"""
|
|
60
79
|
names = {
|
|
61
80
|
ref.name for ref in self.env_vars if include_reserved or not ref.is_reserved
|
|
62
81
|
}
|
|
63
82
|
return sorted(names)
|
|
64
83
|
|
|
65
84
|
def unique_services(self) -> list[str]:
|
|
85
|
+
"""The distinct AWS service ids the code talks to, sorted.
|
|
86
|
+
|
|
87
|
+
``["dynamodb", "s3", "sqs"]`` — one entry per service no matter how many
|
|
88
|
+
call sites mention it.
|
|
89
|
+
"""
|
|
66
90
|
return sorted({ref.service for ref in self.services})
|
|
67
91
|
|
|
68
92
|
def totals(self) -> dict[str, int]:
|
|
93
|
+
"""The headline counts for this package, as a flat dict.
|
|
94
|
+
|
|
95
|
+
The numbers a summary line is built from: how many files, how many bytes,
|
|
96
|
+
and how much of each is first-party code rather than vendored dependency.
|
|
97
|
+
Kept as one dict because it goes straight into the manifest and into the
|
|
98
|
+
index as a row.
|
|
99
|
+
"""
|
|
69
100
|
return {
|
|
70
101
|
"file_count": self.inventory.file_count,
|
|
71
102
|
"total_size": self.inventory.total_size,
|
|
@@ -77,6 +108,17 @@ class Analysis:
|
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
def to_manifest(self, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
111
|
+
"""Render this analysis as the ``manifest.json`` written beside the version.
|
|
112
|
+
|
|
113
|
+
The manifest is the source of truth on disk: the SQLite index is rebuilt
|
|
114
|
+
from these files by :mod:`~lambda_watcher.reindex`, so anything the index
|
|
115
|
+
needs has to be here. ``extra`` carries the fields only the ingest knows —
|
|
116
|
+
the function name, the sequence number, the originating zip — which are
|
|
117
|
+
merged in over the top.
|
|
118
|
+
|
|
119
|
+
Bumping the shape of what this returns means bumping ``MANIFEST_SCHEMA``,
|
|
120
|
+
and old manifests still have to reindex.
|
|
121
|
+
"""
|
|
80
122
|
manifest: dict[str, Any] = {
|
|
81
123
|
"schema": MANIFEST_SCHEMA,
|
|
82
124
|
"tree_hash": self.inventory.tree_hash,
|
lambda_watcher/analysis/deps.py
CHANGED
|
@@ -32,6 +32,14 @@ except ModuleNotFoundError: # pragma: no cover
|
|
|
32
32
|
|
|
33
33
|
@dataclass(frozen=True)
|
|
34
34
|
class Dependency:
|
|
35
|
+
"""One dependency, either declared in a manifest or installed in the zip.
|
|
36
|
+
|
|
37
|
+
``is_declared`` is the important flag. A declared entry came from
|
|
38
|
+
``requirements.txt`` and may be a range (``boto3>=1.34``); an installed
|
|
39
|
+
entry came from ``site-packages`` and is an exact version that really
|
|
40
|
+
shipped (``boto3 1.34.0``). Frozen so it can go in a set.
|
|
41
|
+
"""
|
|
42
|
+
|
|
35
43
|
manager: str # pip | npm | go | maven | gem
|
|
36
44
|
name: str
|
|
37
45
|
version: str | None
|
|
@@ -39,9 +47,16 @@ class Dependency:
|
|
|
39
47
|
is_declared: bool # False => vendored/installed
|
|
40
48
|
|
|
41
49
|
def key(self) -> tuple[str, str]:
|
|
50
|
+
"""Identity across versions: ``(manager, lowercased name)``.
|
|
51
|
+
|
|
52
|
+
Deliberately excludes the version, because this is what a diff groups on to
|
|
53
|
+
notice that ``boto3`` went from 1.34.0 to 1.35.20 rather than reporting one
|
|
54
|
+
package removed and a different one added.
|
|
55
|
+
"""
|
|
42
56
|
return (self.manager, self.name.lower())
|
|
43
57
|
|
|
44
58
|
def as_dict(self) -> dict:
|
|
59
|
+
"""This dependency as plain JSON-ready data, for the manifest."""
|
|
45
60
|
return {
|
|
46
61
|
"manager": self.manager,
|
|
47
62
|
"name": self.name,
|
|
@@ -58,6 +73,16 @@ _REQ_LINE = re.compile(
|
|
|
58
73
|
|
|
59
74
|
|
|
60
75
|
def _parse_requirements(text: str, source: str) -> list[Dependency]:
|
|
76
|
+
"""Parse a ``requirements.txt`` into declared pip dependencies.
|
|
77
|
+
|
|
78
|
+
Handles the ordinary ``boto3==1.34.0`` form plus extras (``requests[security]``),
|
|
79
|
+
direct URLs and ``git+`` references (the trailing path segment becomes the
|
|
80
|
+
name), and PEP 508 ``name @ url`` entries. Comments and the flag lines that
|
|
81
|
+
start with ``-`` (``-r base.txt``, ``-e .``, ``--index-url``) are skipped.
|
|
82
|
+
|
|
83
|
+
A bare ``boto3`` with no comparison operator records a None version — the
|
|
84
|
+
file asked for the package but not for any particular release.
|
|
85
|
+
"""
|
|
61
86
|
deps: list[Dependency] = []
|
|
62
87
|
for raw in text.splitlines():
|
|
63
88
|
line = raw.split("#", 1)[0].strip()
|
|
@@ -80,6 +105,16 @@ def _parse_requirements(text: str, source: str) -> list[Dependency]:
|
|
|
80
105
|
|
|
81
106
|
|
|
82
107
|
def _parse_pyproject(text: str, source: str) -> list[Dependency]:
|
|
108
|
+
"""Parse a ``pyproject.toml`` into declared pip dependencies.
|
|
109
|
+
|
|
110
|
+
Reads both the standard ``[project] dependencies`` list and Poetry's
|
|
111
|
+
``[tool.poetry.dependencies]`` table, whose values may be a bare version
|
|
112
|
+
string or a table with a ``version`` key. Poetry's ``python`` entry is
|
|
113
|
+
dropped: it constrains the interpreter, not the package set.
|
|
114
|
+
|
|
115
|
+
Returns nothing if TOML cannot be parsed — on Python 3.10 ``tomli`` may be
|
|
116
|
+
absent, and a malformed file is not worth failing an ingest over.
|
|
117
|
+
"""
|
|
83
118
|
if tomllib is None:
|
|
84
119
|
return []
|
|
85
120
|
try:
|
|
@@ -108,6 +143,15 @@ def _parse_pyproject(text: str, source: str) -> list[Dependency]:
|
|
|
108
143
|
|
|
109
144
|
|
|
110
145
|
def _parse_package_json(text: str, source: str, declared: bool = True) -> list[Dependency]:
|
|
146
|
+
"""Parse a ``package.json``, either as a manifest or as an installed package.
|
|
147
|
+
|
|
148
|
+
The same filename means two different things depending on where it sits.
|
|
149
|
+
At the package root it is a manifest, and ``declared=True`` reads the
|
|
150
|
+
``dependencies``/``devDependencies``/``optionalDependencies`` tables, whose
|
|
151
|
+
values are ranges like ``^4.17.21``. Inside ``node_modules/<pkg>/`` it
|
|
152
|
+
describes one installed package, and ``declared=False`` takes the file's own
|
|
153
|
+
``name`` and ``version`` — the exact release that shipped.
|
|
154
|
+
"""
|
|
111
155
|
try:
|
|
112
156
|
data = json.loads(text)
|
|
113
157
|
except json.JSONDecodeError:
|
|
@@ -129,6 +173,13 @@ def _parse_package_json(text: str, source: str, declared: bool = True) -> list[D
|
|
|
129
173
|
|
|
130
174
|
|
|
131
175
|
def _parse_package_lock(text: str, source: str) -> list[Dependency]:
|
|
176
|
+
"""Parse a ``package-lock.json`` into installed npm dependencies.
|
|
177
|
+
|
|
178
|
+
Supports both lockfile layouts: v2/v3 keep a flat ``packages`` map keyed by
|
|
179
|
+
path, where the name has to be recovered from the key when the entry omits
|
|
180
|
+
it, while v1 keeps a ``dependencies`` map keyed by name. Both give resolved
|
|
181
|
+
versions, so entries are recorded as installed rather than declared.
|
|
182
|
+
"""
|
|
132
183
|
try:
|
|
133
184
|
data = json.loads(text)
|
|
134
185
|
except json.JSONDecodeError:
|
|
@@ -154,6 +205,12 @@ _YARN_ENTRY = re.compile(r'^"?([^@\s"][^@\s"]*)@[^\n:]*:\s*$\n(?:.*\n)*?\s+versi
|
|
|
154
205
|
|
|
155
206
|
|
|
156
207
|
def _parse_yarn_lock(text: str, source: str) -> list[Dependency]:
|
|
208
|
+
"""Parse a ``yarn.lock`` into installed npm dependencies.
|
|
209
|
+
|
|
210
|
+
Yarn's format is not JSON, so this matches each ``name@range:`` header
|
|
211
|
+
against the indented ``version "1.2.3"`` line that follows it and takes the
|
|
212
|
+
resolved version.
|
|
213
|
+
"""
|
|
157
214
|
deps: list[Dependency] = []
|
|
158
215
|
for match in _YARN_ENTRY.finditer(text):
|
|
159
216
|
deps.append(Dependency("npm", match.group(1), match.group(2), source, False))
|
|
@@ -165,6 +222,11 @@ _GO_REQUIRE_LINE = re.compile(r"^\s*([^\s/]+\S*)\s+(v\S+)", re.MULTILINE)
|
|
|
165
222
|
|
|
166
223
|
|
|
167
224
|
def _parse_go_mod(text: str, source: str) -> list[Dependency]:
|
|
225
|
+
"""Parse a ``go.mod`` into declared Go dependencies.
|
|
226
|
+
|
|
227
|
+
Covers both spellings: the grouped ``require ( ... )`` block and the
|
|
228
|
+
single-line ``require example.com/mod v1.2.3`` form.
|
|
229
|
+
"""
|
|
168
230
|
deps: list[Dependency] = []
|
|
169
231
|
for block in _GO_REQUIRE_BLOCK.findall(text):
|
|
170
232
|
for name, version in _GO_REQUIRE_LINE.findall(block):
|
|
@@ -179,6 +241,16 @@ def _parse_go_mod(text: str, source: str) -> list[Dependency]:
|
|
|
179
241
|
|
|
180
242
|
|
|
181
243
|
def _parse_pom(text: str, source: str) -> list[Dependency]:
|
|
244
|
+
"""Parse a Maven ``pom.xml`` into declared Java dependencies.
|
|
245
|
+
|
|
246
|
+
Each ``<dependency>`` becomes one entry named ``groupId:artifactId``, the
|
|
247
|
+
coordinate Maven itself uses. A ``<version>`` that is a property reference
|
|
248
|
+
(``${aws.sdk.version}``) is recorded verbatim, since resolving it would mean
|
|
249
|
+
evaluating the build.
|
|
250
|
+
|
|
251
|
+
Regex rather than an XML parser because this only needs the common shape and
|
|
252
|
+
must not fail an ingest over an unusual document.
|
|
253
|
+
"""
|
|
182
254
|
deps: list[Dependency] = []
|
|
183
255
|
for block in re.findall(r"<dependency>(.*?)</dependency>", text, re.DOTALL):
|
|
184
256
|
group = re.search(r"<groupId>(.*?)</groupId>", block, re.DOTALL)
|
|
@@ -196,6 +268,11 @@ _GEMFILE_LOCK = re.compile(r"^\s{4}([a-zA-Z0-9_-]+)\s+\(([^)]+)\)", re.MULTILINE
|
|
|
196
268
|
|
|
197
269
|
|
|
198
270
|
def _parse_gemfile_lock(text: str, source: str) -> list[Dependency]:
|
|
271
|
+
"""Parse a ``Gemfile.lock`` into installed Ruby gems.
|
|
272
|
+
|
|
273
|
+
The four-space-indented ``name (1.2.3)`` lines under ``specs:`` are the
|
|
274
|
+
resolved versions, which is why these are recorded as installed.
|
|
275
|
+
"""
|
|
199
276
|
return [
|
|
200
277
|
Dependency("gem", name, version, source, False)
|
|
201
278
|
for name, version in _GEMFILE_LOCK.findall(text)
|
|
@@ -39,18 +39,43 @@ _SCANNABLE = {"python", "javascript", "typescript", "java", "ruby", "csharp", "g
|
|
|
39
39
|
|
|
40
40
|
@dataclass
|
|
41
41
|
class EnvVarRef:
|
|
42
|
+
"""One place in the code where an environment variable is read.
|
|
43
|
+
|
|
44
|
+
The same variable read from three files is three refs; collapse them with
|
|
45
|
+
:meth:`~lambda_watcher.analysis.Analysis.unique_env_vars` when you want the
|
|
46
|
+
set of names rather than the call sites.
|
|
47
|
+
"""
|
|
48
|
+
|
|
42
49
|
name: str
|
|
43
50
|
path: str
|
|
44
51
|
line: int
|
|
45
52
|
is_reserved: bool = False
|
|
46
53
|
|
|
47
54
|
def as_dict(self) -> dict:
|
|
55
|
+
"""This reference as plain JSON-ready data, for the manifest."""
|
|
48
56
|
return {"name": self.name, "path": self.path, "line": self.line, "is_reserved": self.is_reserved}
|
|
49
57
|
|
|
50
58
|
|
|
51
59
|
def detect_env_vars(
|
|
52
60
|
root: Path, inventory: Inventory, include_vendor: bool = False, max_files: int = 2000
|
|
53
61
|
) -> list[EnvVarRef]:
|
|
62
|
+
"""Find every environment variable the code reads, across languages.
|
|
63
|
+
|
|
64
|
+
Scans each text file line by line for the idioms that read configuration —
|
|
65
|
+
``os.environ["X"]`` and ``os.getenv("X")`` in Python, ``process.env.X`` in
|
|
66
|
+
JavaScript, ``System.getenv("X")`` in Java, and the Ruby and C# equivalents
|
|
67
|
+
— and records the name, file and line of each hit.
|
|
68
|
+
|
|
69
|
+
Vendored dependencies are skipped by default: they read hundreds of
|
|
70
|
+
variables that have nothing to do with this function. ``max_files`` caps how
|
|
71
|
+
many files are opened so a package with an enormous tree cannot make an
|
|
72
|
+
ingest crawl. Results are deduplicated by ``(name, path, line)`` and sorted,
|
|
73
|
+
so re-analysing an unchanged tree produces an identical list.
|
|
74
|
+
|
|
75
|
+
This is textual pattern matching, not parsing: a name built at runtime
|
|
76
|
+
(``os.environ[prefix + "_URL"]``) is invisible to it, and one inside a
|
|
77
|
+
comment still counts.
|
|
78
|
+
"""
|
|
54
79
|
refs: list[EnvVarRef] = []
|
|
55
80
|
seen: set[tuple[str, str, int]] = set()
|
|
56
81
|
entries = inventory.files if include_vendor else inventory.code_files
|
|
@@ -31,16 +31,30 @@ _PREFERRED = (
|
|
|
31
31
|
|
|
32
32
|
@dataclass
|
|
33
33
|
class HandlerCandidate:
|
|
34
|
+
"""One possible Lambda entry point, with a score saying how likely it is.
|
|
35
|
+
|
|
36
|
+
``handler`` is the string you would actually paste into the AWS console —
|
|
37
|
+
``lambda_function.lambda_handler`` — assembled from the file's module path
|
|
38
|
+
and the function's name.
|
|
39
|
+
"""
|
|
40
|
+
|
|
34
41
|
path: str
|
|
35
42
|
symbol: str
|
|
36
43
|
handler: str # "module.function", the value you paste into the console
|
|
37
44
|
score: int
|
|
38
45
|
|
|
39
46
|
def as_dict(self) -> dict:
|
|
47
|
+
"""This candidate as plain JSON-ready data, for the manifest."""
|
|
40
48
|
return {"path": self.path, "symbol": self.symbol, "handler": self.handler, "score": self.score}
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
def _module_name(path: str) -> str:
|
|
52
|
+
"""Turn a file path into the dotted module path AWS expects.
|
|
53
|
+
|
|
54
|
+
``src/app/handler.py`` -> ``src.app.handler``. The extension is dropped and
|
|
55
|
+
every directory separator becomes a dot, which is the form the handler
|
|
56
|
+
setting takes.
|
|
57
|
+
"""
|
|
44
58
|
pure = PurePosixPath(path)
|
|
45
59
|
stem = pure.stem
|
|
46
60
|
parts = list(pure.parts[:-1]) + [stem]
|
|
@@ -10,6 +10,14 @@ from ..utils import count_lines, is_probably_text, language_for, matches_any, sh
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
12
12
|
class FileEntry:
|
|
13
|
+
"""One file inside an extracted package, hashed and classified.
|
|
14
|
+
|
|
15
|
+
``path`` is always posix-style and relative to the package root, so the same
|
|
16
|
+
tree hashes identically on Windows and Linux. ``is_vendor`` is the flag most
|
|
17
|
+
of the tool keys off: it separates the handful of files somebody wrote from
|
|
18
|
+
the thousands that came out of ``pip install``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
13
21
|
path: str # posix relative path
|
|
14
22
|
size: int
|
|
15
23
|
sha256: str
|
|
@@ -20,6 +28,7 @@ class FileEntry:
|
|
|
20
28
|
lines: int
|
|
21
29
|
|
|
22
30
|
def as_dict(self) -> dict:
|
|
31
|
+
"""This entry as plain JSON-ready data, for the manifest."""
|
|
23
32
|
return {
|
|
24
33
|
"path": self.path,
|
|
25
34
|
"size": self.size,
|
|
@@ -34,6 +43,14 @@ class FileEntry:
|
|
|
34
43
|
|
|
35
44
|
@dataclass
|
|
36
45
|
class Inventory:
|
|
46
|
+
"""Every file in one extracted package, plus the totals worth caching.
|
|
47
|
+
|
|
48
|
+
``tree_hash`` is the identity of the whole tree and what decides whether an
|
|
49
|
+
ingest has found a new version. The size and line counts are accumulated
|
|
50
|
+
during the walk rather than recomputed, since they are wanted on every
|
|
51
|
+
summary screen.
|
|
52
|
+
"""
|
|
53
|
+
|
|
37
54
|
files: list[FileEntry] = field(default_factory=list)
|
|
38
55
|
tree_hash: str = ""
|
|
39
56
|
total_size: int = 0
|
|
@@ -42,6 +59,7 @@ class Inventory:
|
|
|
42
59
|
|
|
43
60
|
@property
|
|
44
61
|
def file_count(self) -> int:
|
|
62
|
+
"""How many files the package contains, vendored ones included."""
|
|
45
63
|
return len(self.files)
|
|
46
64
|
|
|
47
65
|
@property
|
|
@@ -51,12 +69,24 @@ class Inventory:
|
|
|
51
69
|
|
|
52
70
|
@property
|
|
53
71
|
def code_file_count(self) -> int:
|
|
72
|
+
"""How many first-party files there are — the length of :attr:`code_files`."""
|
|
54
73
|
return len(self.code_files)
|
|
55
74
|
|
|
56
75
|
def by_path(self) -> dict[str, FileEntry]:
|
|
76
|
+
"""The files as a ``{path: entry}`` lookup.
|
|
77
|
+
|
|
78
|
+
Diffing two versions means asking "was this path in the other one too?"
|
|
79
|
+
thousands of times, which wants a dict rather than a scan of the list.
|
|
80
|
+
"""
|
|
57
81
|
return {f.path: f for f in self.files}
|
|
58
82
|
|
|
59
83
|
def language_breakdown(self) -> dict[str, int]:
|
|
84
|
+
"""Count first-party files per language, most common first.
|
|
85
|
+
|
|
86
|
+
``{"python": 12, "json": 3, "markdown": 1}``. Vendored files are excluded
|
|
87
|
+
deliberately — counting them would report the language of the dependencies
|
|
88
|
+
rather than of the function.
|
|
89
|
+
"""
|
|
60
90
|
counts: dict[str, int] = {}
|
|
61
91
|
for f in self.code_files:
|
|
62
92
|
counts[f.lang] = counts.get(f.lang, 0) + 1
|
|
@@ -10,12 +10,20 @@ from .inventory import Inventory
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
12
12
|
class RuntimeGuess:
|
|
13
|
+
"""Which Lambda runtime this package looks like, and why we think so.
|
|
14
|
+
|
|
15
|
+
``evidence`` names the files and extensions that drove the guess, so a
|
|
16
|
+
surprising answer can be argued with rather than just disbelieved, and
|
|
17
|
+
``all_scores`` keeps the runners-up for the same reason.
|
|
18
|
+
"""
|
|
19
|
+
|
|
13
20
|
runtime: str = "unknown"
|
|
14
21
|
confidence: str = "low"
|
|
15
22
|
evidence: list[str] = field(default_factory=list)
|
|
16
23
|
all_scores: dict[str, int] = field(default_factory=dict)
|
|
17
24
|
|
|
18
25
|
def as_dict(self) -> dict:
|
|
26
|
+
"""This guess as plain JSON-ready data, for the manifest."""
|
|
19
27
|
return {
|
|
20
28
|
"runtime": self.runtime,
|
|
21
29
|
"confidence": self.confidence,
|
|
@@ -60,10 +68,30 @@ _EXT_SCORES: dict[str, tuple[str, int]] = {
|
|
|
60
68
|
|
|
61
69
|
|
|
62
70
|
def detect_runtime(inventory: Inventory) -> RuntimeGuess:
|
|
71
|
+
"""Score the package's files to decide which runtime it targets.
|
|
72
|
+
|
|
73
|
+
Two kinds of signal are added up. Marker filenames are strong and specific
|
|
74
|
+
— ``lambda_function.py`` is worth 40 points towards Python, ``go.mod`` 30
|
|
75
|
+
towards Go — while file extensions are weak and cumulative, a few points
|
|
76
|
+
each. The language with the highest total wins.
|
|
77
|
+
|
|
78
|
+
Where a marker sits matters as much as which marker it is. One at the
|
|
79
|
+
package root is what the author intended; the same name inside
|
|
80
|
+
``node_modules`` is somebody else's ``package.json`` and is worth a tenth as
|
|
81
|
+
much, and one buried a few directories down a third. Extensions inside
|
|
82
|
+
vendored trees are ignored outright, since a Python package that vendors a
|
|
83
|
+
JavaScript build tool should still read as Python.
|
|
84
|
+
|
|
85
|
+
Confidence is about the margin, not the total: ``high`` needs both a decisive
|
|
86
|
+
score and twice the runner-up, so a package that genuinely looks like two
|
|
87
|
+
runtimes says so instead of picking one and sounding certain. An empty or
|
|
88
|
+
unrecognisable tree returns the default ``unknown``/``low`` guess.
|
|
89
|
+
"""
|
|
63
90
|
scores: dict[str, int] = {}
|
|
64
91
|
evidence: list[str] = []
|
|
65
92
|
|
|
66
93
|
def bump(lang: str, points: int, why: str) -> None:
|
|
94
|
+
"""Add ``points`` to a language's score, recording ``why`` once."""
|
|
67
95
|
scores[lang] = scores.get(lang, 0) + points
|
|
68
96
|
if why not in evidence:
|
|
69
97
|
evidence.append(why)
|
|
@@ -20,6 +20,12 @@ from .inventory import Inventory
|
|
|
20
20
|
|
|
21
21
|
@dataclass
|
|
22
22
|
class Finding:
|
|
23
|
+
"""One flagged line: a possible credential or a risky call.
|
|
24
|
+
|
|
25
|
+
``detail`` is safe to print and store. For a matched secret it is already
|
|
26
|
+
redacted by :func:`_redact`; the value itself never leaves this module.
|
|
27
|
+
"""
|
|
28
|
+
|
|
23
29
|
kind: str
|
|
24
30
|
severity: str # high | medium | low
|
|
25
31
|
path: str
|
|
@@ -28,6 +34,7 @@ class Finding:
|
|
|
28
34
|
is_vendor: bool = False
|
|
29
35
|
|
|
30
36
|
def as_dict(self) -> dict:
|
|
37
|
+
"""This finding as plain JSON-ready data, for the manifest."""
|
|
31
38
|
return {
|
|
32
39
|
"kind": self.kind,
|
|
33
40
|
"severity": self.severity,
|
|
@@ -40,6 +47,14 @@ class Finding:
|
|
|
40
47
|
|
|
41
48
|
@dataclass(frozen=True)
|
|
42
49
|
class Rule:
|
|
50
|
+
"""One pattern to look for, and how loudly to complain when it matches.
|
|
51
|
+
|
|
52
|
+
``group`` names the capture group holding the interesting value. A rule with
|
|
53
|
+
``group=0`` has nothing to extract — ``-----BEGIN PRIVATE KEY-----`` is the
|
|
54
|
+
whole finding — and that difference decides both whether the placeholder
|
|
55
|
+
filter runs and whether the detail is redacted.
|
|
56
|
+
"""
|
|
57
|
+
|
|
43
58
|
kind: str
|
|
44
59
|
severity: str
|
|
45
60
|
pattern: re.Pattern[str]
|
|
@@ -103,6 +118,12 @@ _SCANNABLE = {
|
|
|
103
118
|
|
|
104
119
|
|
|
105
120
|
def _shannon_entropy(value: str) -> float:
|
|
121
|
+
"""Bits of entropy per character, used to tell keys from words.
|
|
122
|
+
|
|
123
|
+
A real credential draws on the whole alphabet fairly evenly and scores high;
|
|
124
|
+
an English word or a repeated placeholder scores low. Used only as one input
|
|
125
|
+
to :func:`_is_placeholder`, never on its own.
|
|
126
|
+
"""
|
|
106
127
|
if not value:
|
|
107
128
|
return 0.0
|
|
108
129
|
counts: dict[str, int] = {}
|
|
@@ -113,6 +134,13 @@ def _shannon_entropy(value: str) -> float:
|
|
|
113
134
|
|
|
114
135
|
|
|
115
136
|
def _redact(value: str) -> str:
|
|
137
|
+
"""Render a matched value so it can be stored without storing the secret.
|
|
138
|
+
|
|
139
|
+
``AKIAIOSFODNN7EXAMPLE`` -> ``AKIA…LE (20 chars)``. Enough to recognise the
|
|
140
|
+
value again and to see it change between versions, not enough to use. Short
|
|
141
|
+
values are replaced by stars entirely, since four of eight characters would
|
|
142
|
+
give too much away.
|
|
143
|
+
"""
|
|
116
144
|
value = value.strip()
|
|
117
145
|
if len(value) <= 8:
|
|
118
146
|
return "*" * len(value)
|
|
@@ -120,6 +148,18 @@ def _redact(value: str) -> str:
|
|
|
120
148
|
|
|
121
149
|
|
|
122
150
|
def _is_placeholder(value: str) -> bool:
|
|
151
|
+
"""True when a matched value is obviously not a live credential.
|
|
152
|
+
|
|
153
|
+
The scanner would be useless if every ``password = "changeme"`` in an
|
|
154
|
+
example file produced a high-severity finding. Three things disqualify a
|
|
155
|
+
match: a known placeholder shape (``xxxx``, ``<your-key>``, ``${VAR}``,
|
|
156
|
+
``TODO``), a value that is plainly an environment lookup rather than a
|
|
157
|
+
literal, and a long string whose entropy is too low to be a key — see
|
|
158
|
+
:func:`_shannon_entropy`.
|
|
159
|
+
|
|
160
|
+
Tuned to under-report. A missed secret is a gap in a tripwire; a wall of
|
|
161
|
+
false positives is a feature people switch off.
|
|
162
|
+
"""
|
|
123
163
|
stripped = value.strip()
|
|
124
164
|
if not stripped or _PLACEHOLDER.match(stripped):
|
|
125
165
|
return True
|
|
@@ -136,6 +176,25 @@ def scan(
|
|
|
136
176
|
check_secrets: bool = True,
|
|
137
177
|
max_files: int = 3000,
|
|
138
178
|
) -> list[Finding]:
|
|
179
|
+
"""Scan the package for credentials and risky calls, worst first.
|
|
180
|
+
|
|
181
|
+
Runs two rule sets over every scannable text file: :data:`SECRET_RULES`,
|
|
182
|
+
which look for things that should never be in a zip (AWS keys, private
|
|
183
|
+
keys, GitHub and Slack tokens, connection strings with passwords in them),
|
|
184
|
+
and :data:`RISK_RULES`, which look for patterns worth a second glance
|
|
185
|
+
(``eval(``, ``shell=True``, ``verify=False``). Setting ``check_secrets``
|
|
186
|
+
False keeps only the second set.
|
|
187
|
+
|
|
188
|
+
Several limits keep this cheap and quiet rather than exhaustive. Vendored
|
|
189
|
+
files are skipped by default, files over 2 MB are not opened, ``max_files``
|
|
190
|
+
caps the walk, and lines longer than 4,000 characters are ignored because a
|
|
191
|
+
minified bundle matches everything and means nothing. Secret matches are
|
|
192
|
+
filtered through :func:`_is_placeholder`, and each ``(kind, value)`` pair is
|
|
193
|
+
reported once per file rather than once per occurrence.
|
|
194
|
+
|
|
195
|
+
Findings come back sorted by severity then location, so the first row is
|
|
196
|
+
the one worth reading. Values are redacted before they are returned.
|
|
197
|
+
"""
|
|
139
198
|
findings: list[Finding] = []
|
|
140
199
|
entries = inventory.files if include_vendor else inventory.code_files
|
|
141
200
|
rules = (SECRET_RULES if check_secrets else []) + RISK_RULES
|
|
@@ -38,17 +38,37 @@ _ALIASES = {
|
|
|
38
38
|
|
|
39
39
|
@dataclass
|
|
40
40
|
class ServiceRef:
|
|
41
|
+
"""One place in the code where an AWS service is used.
|
|
42
|
+
|
|
43
|
+
Recorded per ``(service, file)`` rather than per line — the first mention in
|
|
44
|
+
a file is enough to say the file talks to DynamoDB.
|
|
45
|
+
"""
|
|
46
|
+
|
|
41
47
|
service: str
|
|
42
48
|
path: str
|
|
43
49
|
line: int
|
|
44
50
|
|
|
45
51
|
def as_dict(self) -> dict:
|
|
52
|
+
"""This reference as plain JSON-ready data, for the manifest."""
|
|
46
53
|
return {"service": self.service, "path": self.path, "line": self.line}
|
|
47
54
|
|
|
48
55
|
|
|
49
56
|
def detect_services(
|
|
50
57
|
root: Path, inventory: Inventory, max_files: int = 2000
|
|
51
58
|
) -> list[ServiceRef]:
|
|
59
|
+
"""Find which AWS services the code calls, across SDK dialects.
|
|
60
|
+
|
|
61
|
+
Recognises the shapes each SDK uses to name a service: ``boto3.client("s3")``
|
|
62
|
+
in Python, the ``@aws-sdk/client-dynamodb`` import and ``new AWS.S3(`` in
|
|
63
|
+
JavaScript, and the ``com.amazonaws.services.*`` and
|
|
64
|
+
``software.amazon.awssdk.services.*`` package paths in Java. The captured
|
|
65
|
+
name is lowercased and mapped through ``_ALIASES`` so the spellings converge
|
|
66
|
+
— ``sfn`` and ``states`` both become ``stepfunctions``.
|
|
67
|
+
|
|
68
|
+
Only first-party code is scanned, and only the first hit per
|
|
69
|
+
``(service, file)`` is kept, so vendoring the AWS SDK does not make a
|
|
70
|
+
function look like it calls every service Amazon sells.
|
|
71
|
+
"""
|
|
52
72
|
refs: list[ServiceRef] = []
|
|
53
73
|
seen: set[tuple[str, str]] = set()
|
|
54
74
|
scanned = 0
|