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.
Files changed (35) hide show
  1. lambda_watcher/__init__.py +1 -1
  2. lambda_watcher/analysis/__init__.py +42 -0
  3. lambda_watcher/analysis/deps.py +77 -0
  4. lambda_watcher/analysis/envvars.py +25 -0
  5. lambda_watcher/analysis/handler.py +14 -0
  6. lambda_watcher/analysis/inventory.py +30 -0
  7. lambda_watcher/analysis/runtime.py +28 -0
  8. lambda_watcher/analysis/secrets.py +59 -0
  9. lambda_watcher/analysis/services.py +20 -0
  10. lambda_watcher/cli.py +200 -28
  11. lambda_watcher/config.py +81 -11
  12. lambda_watcher/db.py +152 -2
  13. lambda_watcher/diffing/compare.py +604 -75
  14. lambda_watcher/diffing/highlight.py +24 -2
  15. lambda_watcher/diffing/intraline.py +181 -4
  16. lambda_watcher/diffing/render_html.py +237 -20
  17. lambda_watcher/diffing/render_text.py +192 -12
  18. lambda_watcher/extract.py +19 -0
  19. lambda_watcher/gitmirror.py +105 -6
  20. lambda_watcher/identify.py +16 -2
  21. lambda_watcher/ingest.py +51 -0
  22. lambda_watcher/notify.py +11 -0
  23. lambda_watcher/reindex.py +17 -0
  24. lambda_watcher/service.py +327 -36
  25. lambda_watcher/store.py +51 -0
  26. lambda_watcher/templates.py +46 -1
  27. lambda_watcher/utils.py +73 -0
  28. lambda_watcher/watcher.py +93 -6
  29. {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/METADATA +36 -21
  30. lambda_watcher-0.2.0.dist-info/RECORD +38 -0
  31. lambda_watcher-0.1.0.dist-info/RECORD +0 -38
  32. {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/WHEEL +0 -0
  33. {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/entry_points.txt +0 -0
  34. {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/licenses/LICENSE +0 -0
  35. {lambda_watcher-0.1.0.dist-info → lambda_watcher-0.2.0.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
1
  """Watch a downloads folder for AWS Lambda deployment packages and version them."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
  __all__ = ["__version__"]
@@ -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,
@@ -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