skilltotal 0.76.0__tar.gz → 0.78.0__tar.gz
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.
- {skilltotal-0.76.0/skilltotal.egg-info → skilltotal-0.78.0}/PKG-INFO +7 -5
- {skilltotal-0.76.0 → skilltotal-0.78.0}/README.md +6 -4
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/__init__.py +2 -2
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/agent_hook.py +8 -2
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/autorun.py +27 -8
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/cli.py +56 -21
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/diff.py +43 -6
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/engine.py +44 -11
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/file_index.py +5 -1
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/loaded_code.py +10 -5
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/mcp_server.py +25 -7
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/models.py +2 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/report.py +22 -9
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/agent_approval.py +48 -13
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/agent_config.py +6 -2
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/dependency_redirect.py +43 -1
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/mcp.py +9 -3
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/obfuscation.py +18 -3
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/pth_exec.py +4 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/secret_exfil.py +58 -14
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/sensitive_paths.py +18 -3
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/shell_script.py +22 -1
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scoring.py +9 -11
- {skilltotal-0.76.0 → skilltotal-0.78.0/skilltotal.egg-info}/PKG-INFO +7 -5
- {skilltotal-0.76.0 → skilltotal-0.78.0}/LICENSE +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/MANIFEST.in +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/pyproject.toml +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/setup.cfg +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/__main__.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/agent_skill.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/baseline.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/bounded_json.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/capabilities.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/collector.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/combinations.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/concat_normalize.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/config.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/guard.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/hook_scan.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/inventory.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/owasp.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/parallel.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/provenance.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/rules.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/sarif.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/sbom.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/__init__.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/base.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/clipper.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/dynamic_code.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/email_exfil.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/encrypted_archive.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/exposure.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/filesystem.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/install_scripts.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/invisible_unicode.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/mcp_elicitation.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/mcp_pinning.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/network.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/npm_supply.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/oauth_auth.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/prompt_surface.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/python_ast.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/scoped_identity.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/secrets.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/shell_evasion.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/shell_exec.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/skill_fetch_exec.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/text_normalize.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/traits.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/typosquatting.py +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/SOURCES.txt +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/dependency_links.txt +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/entry_points.txt +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/requires.txt +0 -0
- {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skilltotal
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.78.0
|
|
4
4
|
Summary: Static security scanner for npm and PyPI packages, MCP servers and agent skills. Runs locally, never runs the code, no LLM.
|
|
5
5
|
Author: SkillTotal
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -102,7 +102,7 @@ skilltotal scan npm:express # or pypi:name, a git URL, a folder, an archive
|
|
|
102
102
|
([full example with permissions](https://github.com/pezhik/skilltotal/blob/main/docs/ci.md)):
|
|
103
103
|
|
|
104
104
|
```yaml
|
|
105
|
-
- uses: pezhik/skilltotal@v0.
|
|
105
|
+
- uses: pezhik/skilltotal@v0.78.0
|
|
106
106
|
with: { source: ., fail-on: high }
|
|
107
107
|
```
|
|
108
108
|
|
|
@@ -241,7 +241,7 @@ jobs:
|
|
|
241
241
|
runs-on: ubuntu-latest
|
|
242
242
|
steps:
|
|
243
243
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
244
|
-
- uses: pezhik/skilltotal@v0.
|
|
244
|
+
- uses: pezhik/skilltotal@v0.78.0
|
|
245
245
|
with:
|
|
246
246
|
source: . # a path, a git URL, or an npm:/pypi:<name> spec
|
|
247
247
|
fail-on: high # fail on a high/critical risk finding (or 'none')
|
|
@@ -259,7 +259,7 @@ out on an existing repository. Anywhere else Python 3.10+ runs, install a pinned
|
|
|
259
259
|
on the exit code (0 passed, 2 a gate tripped, 1 the scan could not run):
|
|
260
260
|
|
|
261
261
|
```bash
|
|
262
|
-
pip install skilltotal==0.
|
|
262
|
+
pip install skilltotal==0.78.0 # in a virtual environment outside the checkout
|
|
263
263
|
skilltotal scan . --fail-on high
|
|
264
264
|
```
|
|
265
265
|
|
|
@@ -317,7 +317,7 @@ With npx (it needs uv, pipx, or Python 3.11+ with `skilltotal` installed), name
|
|
|
317
317
|
`--package` so npx does not run a `skilltotal` from the scanned repository's `node_modules/.bin`:
|
|
318
318
|
|
|
319
319
|
```bash
|
|
320
|
-
npx -y --package=skilltotal@0.
|
|
320
|
+
npx -y --package=skilltotal@0.78.0 -- skilltotal scan https://github.com/owner/repo
|
|
321
321
|
```
|
|
322
322
|
|
|
323
323
|
## Measured, not asserted
|
|
@@ -326,6 +326,8 @@ npx -y --package=skilltotal@0.76.0 -- skilltotal scan https://github.com/owner/r
|
|
|
326
326
|
recall and precision on a labeled corpus we built and publish (130 malicious and
|
|
327
327
|
94 benign samples), regenerated every release and enforced by CI as a floor. A perfect score on
|
|
328
328
|
our own corpus guards against regressions; it is not a claim about every attack in the wild.
|
|
329
|
+
Each rule behind the survey's OWASP class counts also has labeled samples of its own, listed in
|
|
330
|
+
the report's rule coverage table.
|
|
329
331
|
- **[The MCP registry, scanned](https://github.com/pezhik/skilltotal/blob/main/docs/mcp-registry-survey.md)**
|
|
330
332
|
(September 2026, engine 0.49.0, rulesets 56 to 59; later rule changes are not reflected): 15,341
|
|
331
333
|
of the 17,535 distinct components in the official registry (87.5%). Of those scanned, 88.2%
|
|
@@ -61,7 +61,7 @@ skilltotal scan npm:express # or pypi:name, a git URL, a folder, an archive
|
|
|
61
61
|
([full example with permissions](https://github.com/pezhik/skilltotal/blob/main/docs/ci.md)):
|
|
62
62
|
|
|
63
63
|
```yaml
|
|
64
|
-
- uses: pezhik/skilltotal@v0.
|
|
64
|
+
- uses: pezhik/skilltotal@v0.78.0
|
|
65
65
|
with: { source: ., fail-on: high }
|
|
66
66
|
```
|
|
67
67
|
|
|
@@ -200,7 +200,7 @@ jobs:
|
|
|
200
200
|
runs-on: ubuntu-latest
|
|
201
201
|
steps:
|
|
202
202
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
203
|
-
- uses: pezhik/skilltotal@v0.
|
|
203
|
+
- uses: pezhik/skilltotal@v0.78.0
|
|
204
204
|
with:
|
|
205
205
|
source: . # a path, a git URL, or an npm:/pypi:<name> spec
|
|
206
206
|
fail-on: high # fail on a high/critical risk finding (or 'none')
|
|
@@ -218,7 +218,7 @@ out on an existing repository. Anywhere else Python 3.10+ runs, install a pinned
|
|
|
218
218
|
on the exit code (0 passed, 2 a gate tripped, 1 the scan could not run):
|
|
219
219
|
|
|
220
220
|
```bash
|
|
221
|
-
pip install skilltotal==0.
|
|
221
|
+
pip install skilltotal==0.78.0 # in a virtual environment outside the checkout
|
|
222
222
|
skilltotal scan . --fail-on high
|
|
223
223
|
```
|
|
224
224
|
|
|
@@ -276,7 +276,7 @@ With npx (it needs uv, pipx, or Python 3.11+ with `skilltotal` installed), name
|
|
|
276
276
|
`--package` so npx does not run a `skilltotal` from the scanned repository's `node_modules/.bin`:
|
|
277
277
|
|
|
278
278
|
```bash
|
|
279
|
-
npx -y --package=skilltotal@0.
|
|
279
|
+
npx -y --package=skilltotal@0.78.0 -- skilltotal scan https://github.com/owner/repo
|
|
280
280
|
```
|
|
281
281
|
|
|
282
282
|
## Measured, not asserted
|
|
@@ -285,6 +285,8 @@ npx -y --package=skilltotal@0.76.0 -- skilltotal scan https://github.com/owner/r
|
|
|
285
285
|
recall and precision on a labeled corpus we built and publish (130 malicious and
|
|
286
286
|
94 benign samples), regenerated every release and enforced by CI as a floor. A perfect score on
|
|
287
287
|
our own corpus guards against regressions; it is not a claim about every attack in the wild.
|
|
288
|
+
Each rule behind the survey's OWASP class counts also has labeled samples of its own, listed in
|
|
289
|
+
the report's rule coverage table.
|
|
288
290
|
- **[The MCP registry, scanned](https://github.com/pezhik/skilltotal/blob/main/docs/mcp-registry-survey.md)**
|
|
289
291
|
(September 2026, engine 0.49.0, rulesets 56 to 59; later rule changes are not reflected): 15,341
|
|
290
292
|
of the 17,535 distinct components in the official registry (87.5%). Of those scanned, 88.2%
|
|
@@ -22,10 +22,10 @@ from skilltotal.models import (
|
|
|
22
22
|
# REPORT_SCHEMA_VERSION: shape of Report.to_dict(); bumps only on schema changes.
|
|
23
23
|
# RULESET_VERSION: integer counter of the detection ruleset; bumps when rules change, so a
|
|
24
24
|
# consumer knows when re-scanning old reports may surface new findings.
|
|
25
|
-
__version__ = "0.
|
|
25
|
+
__version__ = "0.78.0"
|
|
26
26
|
ENGINE_VERSION = __version__
|
|
27
27
|
REPORT_SCHEMA_VERSION = "1.9"
|
|
28
|
-
RULESET_VERSION =
|
|
28
|
+
RULESET_VERSION = 79
|
|
29
29
|
|
|
30
30
|
__all__ = [
|
|
31
31
|
"__version__",
|
|
@@ -193,8 +193,10 @@ _INSTALL_WORDS = re.compile(
|
|
|
193
193
|
r"(?<![\w.-])(?:install|isntall|i|in|it|add|create|init|innit|exec|x|dlx|tool|run)(?![\w.-]))",
|
|
194
194
|
re.IGNORECASE,
|
|
195
195
|
)
|
|
196
|
-
# A word the shell fills in when it runs: a variable, a substitution, a brace expansion
|
|
197
|
-
|
|
196
|
+
# A word the shell fills in when it runs: a variable, a substitution, a brace expansion, or cmd's
|
|
197
|
+
# `%NAME%` / delayed `!NAME!` (`set V=install & npm %V% x` installs x: read as a plain word, `%V%`
|
|
198
|
+
# was no subcommand the hook knew, and the line went through unasked).
|
|
199
|
+
_COMPUTED = re.compile(r"[$`{}]|%[A-Za-z_][^%\s]*%|![A-Za-z_][^!\s]*!")
|
|
198
200
|
|
|
199
201
|
|
|
200
202
|
def _globbed(word: str) -> bool:
|
|
@@ -3914,6 +3916,8 @@ def _uv(rest: list[str], shell: str, depth: int, level: _Level) -> list[Install]
|
|
|
3914
3916
|
return [] if _help(args, _PY_HELP) else _lockfile("pyproject.toml")
|
|
3915
3917
|
if sub == "pip":
|
|
3916
3918
|
sub2, args2 = _subcommand(args, _PIP_VALUE_FLAGS | _UV_VALUE_FLAGS)
|
|
3919
|
+
if _filled_in(sub2 or ""):
|
|
3920
|
+
return [_NOT_READ] # `uv pip $V x`: filled in when the line runs
|
|
3917
3921
|
if sub2 == "sync": # `uv pip sync requirements.txt`
|
|
3918
3922
|
if _help(args2, _PY_HELP):
|
|
3919
3923
|
return []
|
|
@@ -3922,6 +3926,8 @@ def _uv(rest: list[str], shell: str, depth: int, level: _Level) -> list[Install]
|
|
|
3922
3926
|
return _pip_install(args2, "uv", index) if sub2 == "install" else []
|
|
3923
3927
|
if sub == "tool":
|
|
3924
3928
|
sub2, args2 = _subcommand(args, _UV_VALUE_FLAGS)
|
|
3929
|
+
if _filled_in(sub2 or ""):
|
|
3930
|
+
return [_NOT_READ]
|
|
3925
3931
|
if sub2 == "install":
|
|
3926
3932
|
return _first_py(args2, index) + _with_values(args2, index)
|
|
3927
3933
|
if sub2 == "run":
|
|
@@ -78,6 +78,8 @@ _MCP_MANIFEST_NAMES = frozenset(
|
|
|
78
78
|
# Bundles Claude Code unpacks and runs: their code is not in the files this scan reads.
|
|
79
79
|
_BUNDLE_SUFFIXES = (".mcpb", ".dxt")
|
|
80
80
|
MCP_BUNDLE_NOT_ANALYZED = "MCP server bundle not analyzed"
|
|
81
|
+
# A skill's or subagent's frontmatter longer than this reader goes (_MAX_FRONTMATTER_LINES).
|
|
82
|
+
FRONTMATTER_PARTLY_READ = "Frontmatter partly analyzed"
|
|
81
83
|
|
|
82
84
|
|
|
83
85
|
@dataclass(frozen=True)
|
|
@@ -219,6 +221,9 @@ class _Collector:
|
|
|
219
221
|
or lower.endswith((_VSCODE_TASKS, _VSCODE_SETTINGS))
|
|
220
222
|
or name in ("mcp.json", "manifest.json")
|
|
221
223
|
or '"mcpServers"' in text
|
|
224
|
+
# A key written with escapes (`"mcp\u0053ervers"`) is the same name to a JSON
|
|
225
|
+
# reader, so a file with any escape is parsed and its decoded keys decide.
|
|
226
|
+
or "\\u" in text
|
|
222
227
|
)
|
|
223
228
|
if not candidate:
|
|
224
229
|
return
|
|
@@ -453,7 +458,7 @@ class _Collector:
|
|
|
453
458
|
ref_data = self._load(ref) if ref is not None else None
|
|
454
459
|
if not isinstance(ref_data, dict) or ref is None:
|
|
455
460
|
return
|
|
456
|
-
if
|
|
461
|
+
if isinstance(ref_data.get("mcpServers"), dict):
|
|
457
462
|
return # read on its own like any file with an `mcpServers` map
|
|
458
463
|
self._servers(ref, _Anchors(ref), ref_data, ORIGIN_PLUGIN, "referenced")
|
|
459
464
|
|
|
@@ -482,9 +487,22 @@ class _Collector:
|
|
|
482
487
|
|
|
483
488
|
# -- Markdown frontmatter -----------------------------------------------------------------
|
|
484
489
|
def frontmatter(self, f: IndexedFile, *, skill: bool) -> None:
|
|
485
|
-
block = _frontmatter_lines(f.text)
|
|
490
|
+
block, cut = _frontmatter_lines(f.text)
|
|
486
491
|
if not block:
|
|
487
492
|
return
|
|
493
|
+
if cut:
|
|
494
|
+
# The agent reads all of it: hooks or servers declared past the limit would run
|
|
495
|
+
# unchecked, so the report says the rest was not read.
|
|
496
|
+
self.reviews.append(NeedsReview(
|
|
497
|
+
category="coverage",
|
|
498
|
+
title=FRONTMATTER_PARTLY_READ,
|
|
499
|
+
reason=(
|
|
500
|
+
f"The frontmatter is longer than {_MAX_FRONTMATTER_LINES} lines; hooks or MCP "
|
|
501
|
+
"servers declared past that were not analyzed. Read the rest before using it."
|
|
502
|
+
),
|
|
503
|
+
file=f.relpath,
|
|
504
|
+
line=f.line_of_offset(block[-1].start),
|
|
505
|
+
))
|
|
488
506
|
found = False
|
|
489
507
|
for kind, origin, piece in _frontmatter_hooks(block):
|
|
490
508
|
found = True
|
|
@@ -759,16 +777,17 @@ class _Line:
|
|
|
759
777
|
return len(self.text) - len(self.text.lstrip(" \t"))
|
|
760
778
|
|
|
761
779
|
|
|
762
|
-
def _frontmatter_lines(text: str) -> list[_Line]:
|
|
763
|
-
"""The lines of a leading ``---`` YAML frontmatter block, with their file offsets
|
|
780
|
+
def _frontmatter_lines(text: str) -> tuple[list[_Line], bool]:
|
|
781
|
+
"""The lines of a leading ``---`` YAML frontmatter block, with their file offsets, and whether
|
|
782
|
+
the block goes on past ``_MAX_FRONTMATTER_LINES`` (the rest is not read)."""
|
|
764
783
|
if not text.startswith("---"):
|
|
765
|
-
return []
|
|
784
|
+
return [], False
|
|
766
785
|
first_nl = text.find("\n")
|
|
767
786
|
if first_nl < 0:
|
|
768
|
-
return []
|
|
787
|
+
return [], False
|
|
769
788
|
end = _FRONTMATTER_END.search(text, first_nl + 1)
|
|
770
789
|
if end is None:
|
|
771
|
-
return []
|
|
790
|
+
return [], False
|
|
772
791
|
lines: list[_Line] = []
|
|
773
792
|
pos = first_nl + 1
|
|
774
793
|
while pos < end.start() and len(lines) < _MAX_FRONTMATTER_LINES:
|
|
@@ -776,7 +795,7 @@ def _frontmatter_lines(text: str) -> list[_Line]:
|
|
|
776
795
|
stop = end.start() if nl < 0 else nl
|
|
777
796
|
lines.append(_Line(text[pos:stop].rstrip("\r"), pos)) # a CRLF file reads the same
|
|
778
797
|
pos = stop + 1
|
|
779
|
-
return lines
|
|
798
|
+
return lines, pos < end.start()
|
|
780
799
|
|
|
781
800
|
|
|
782
801
|
def _yaml_scalar(raw: str, offset: int) -> tuple[str, int, int]:
|
|
@@ -15,10 +15,12 @@ Commands:
|
|
|
15
15
|
A config that was applied is named on the console. CLI flags override config. Inline
|
|
16
16
|
`# skilltotal:ignore[ST-ID]` on a finding's line, or on the line above it, suppresses it
|
|
17
17
|
when the source is a local directory (never in a fetched package or repository).
|
|
18
|
-
skilltotal diff <old> <new> [--json] [--output FILE] [--fail-on-new LEVEL]
|
|
18
|
+
skilltotal diff <old> <new> [--json] [--output FILE] [--fail-on-new LEVEL]
|
|
19
|
+
[--fail-on-unanalyzed] [--no-config]
|
|
19
20
|
compare two versions of a component: each side is any scannable source (as in
|
|
20
21
|
`scan`) or a previously saved JSON report. Reports new/resolved findings,
|
|
21
|
-
evidence-level changes, and
|
|
22
|
+
evidence-level changes, capability changes, and what only the new version's scan
|
|
23
|
+
could not analyze.
|
|
22
24
|
skilltotal guard <source> [--block-on malicious|high|medium] [--json]
|
|
23
25
|
skilltotal guard --installed [--project DIR] [--block-on ...] [--json]
|
|
24
26
|
pre-install allow/block decision (exit 2 on block): malicious indicators always
|
|
@@ -254,6 +256,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
254
256
|
"capabilities never do."
|
|
255
257
|
),
|
|
256
258
|
)
|
|
259
|
+
diff.add_argument(
|
|
260
|
+
"--fail-on-unanalyzed",
|
|
261
|
+
action="store_true",
|
|
262
|
+
help=(
|
|
263
|
+
"Exit with code 2 if the new version has a file, or part of one, that could not be "
|
|
264
|
+
"analyzed and the old version did not: a finding that disappeared with it may only "
|
|
265
|
+
"be out of view."
|
|
266
|
+
),
|
|
267
|
+
)
|
|
257
268
|
diff.add_argument(
|
|
258
269
|
"--exclude",
|
|
259
270
|
metavar="GLOB",
|
|
@@ -691,7 +702,7 @@ def _cmd_scan(args: argparse.Namespace) -> int:
|
|
|
691
702
|
try:
|
|
692
703
|
config = _load_config(args)
|
|
693
704
|
except ConfigError as exc: # a gate that would silently be off
|
|
694
|
-
print(f"error: {exc}", file=sys.stderr)
|
|
705
|
+
print(f"error: {_diagnostic(str(exc))}", file=sys.stderr)
|
|
695
706
|
return EXIT_ERROR
|
|
696
707
|
|
|
697
708
|
# A report the scan cannot write is known before the scan, which can take minutes.
|
|
@@ -714,7 +725,8 @@ def _cmd_scan(args: argparse.Namespace) -> int:
|
|
|
714
725
|
try:
|
|
715
726
|
suppress = load_baseline(baseline_path)
|
|
716
727
|
except (OSError, json.JSONDecodeError) as exc:
|
|
717
|
-
print(f"error: cannot read baseline {baseline_path}:
|
|
728
|
+
print(f"error: cannot read baseline {_one_line(str(baseline_path))}: "
|
|
729
|
+
f"{_diagnostic(str(exc))}", file=sys.stderr)
|
|
718
730
|
return EXIT_ERROR
|
|
719
731
|
|
|
720
732
|
exclude = [*config.exclude, *args.exclude, *_own_report(args.source, args.output)]
|
|
@@ -739,17 +751,17 @@ def _cmd_scan(args: argparse.Namespace) -> int:
|
|
|
739
751
|
)
|
|
740
752
|
report.component.source = args.source # not the snapshot's temporary path
|
|
741
753
|
except StagedError as exc:
|
|
742
|
-
print(f"error: --staged: {exc}", file=sys.stderr)
|
|
754
|
+
print(f"error: --staged: {_diagnostic(str(exc))}", file=sys.stderr)
|
|
743
755
|
return EXIT_ERROR
|
|
744
756
|
except CollectionError as exc:
|
|
745
757
|
from skilltotal.collector import failure_category
|
|
746
758
|
|
|
747
759
|
# A repository that does not exist reads as git asking for a password.
|
|
748
760
|
if failure_category(exc) == "repo-not-found":
|
|
749
|
-
print(f"error: {args.source}: the repository was not found, or it is
|
|
750
|
-
"(a private repository needs git credentials)", file=sys.stderr)
|
|
761
|
+
print(f"error: {_one_line(args.source)}: the repository was not found, or it is "
|
|
762
|
+
"private (a private repository needs git credentials)", file=sys.stderr)
|
|
751
763
|
else:
|
|
752
|
-
print(f"error: {exc}", file=sys.stderr)
|
|
764
|
+
print(f"error: {_diagnostic(str(exc))}", file=sys.stderr)
|
|
753
765
|
if _joined_by_trailing_backslash(args.source):
|
|
754
766
|
print("hint: a trailing backslash before the closing quote joined your arguments; "
|
|
755
767
|
"drop the trailing \\ (write '.\\my folder', not '.\\my folder\\')",
|
|
@@ -784,7 +796,7 @@ def _cmd_scan(args: argparse.Namespace) -> int:
|
|
|
784
796
|
try:
|
|
785
797
|
signals = collect_provenance(args.source)
|
|
786
798
|
except ProvenanceError as exc:
|
|
787
|
-
print(f"warning: {exc}", file=sys.stderr)
|
|
799
|
+
print(f"warning: {_diagnostic(str(exc))}", file=sys.stderr)
|
|
788
800
|
else:
|
|
789
801
|
report.needs_review.extend(signals)
|
|
790
802
|
report.metadata["provenance_checked"] = True
|
|
@@ -927,7 +939,7 @@ def _cmd_diff(args: argparse.Namespace) -> int:
|
|
|
927
939
|
try:
|
|
928
940
|
config = _load_config(args)
|
|
929
941
|
except ConfigError as exc: # a gate that would silently be off
|
|
930
|
-
print(f"error: {exc}", file=sys.stderr)
|
|
942
|
+
print(f"error: {_diagnostic(str(exc))}", file=sys.stderr)
|
|
931
943
|
return EXIT_ERROR
|
|
932
944
|
exclude = [*config.exclude, *args.exclude]
|
|
933
945
|
problem = _output_problem(args.output) if args.output else ""
|
|
@@ -937,18 +949,24 @@ def _cmd_diff(args: argparse.Namespace) -> int:
|
|
|
937
949
|
return EXIT_ERROR
|
|
938
950
|
|
|
939
951
|
try:
|
|
940
|
-
old_report = _resolve_diff_side(args.old, config, exclude)
|
|
941
|
-
new_report = _resolve_diff_side(args.new, config, exclude)
|
|
952
|
+
old_report, old_saved = _resolve_diff_side(args.old, config, exclude)
|
|
953
|
+
new_report, new_saved = _resolve_diff_side(args.new, config, exclude)
|
|
942
954
|
except CollectionError as exc:
|
|
943
|
-
print(f"error: {exc}", file=sys.stderr)
|
|
955
|
+
print(f"error: {_diagnostic(str(exc))}", file=sys.stderr)
|
|
944
956
|
return EXIT_ERROR
|
|
945
957
|
except (OSError, json.JSONDecodeError) as exc:
|
|
946
|
-
print(f"error: cannot read report: {exc}", file=sys.stderr)
|
|
958
|
+
print(f"error: cannot read report: {_diagnostic(str(exc))}", file=sys.stderr)
|
|
947
959
|
return EXIT_ERROR
|
|
948
960
|
|
|
949
961
|
diff = diff_reports(old_report, new_report)
|
|
950
962
|
if config.path is not None:
|
|
951
963
|
print(f"config applied: {_one_line(config.describe())}", file=sys.stderr)
|
|
964
|
+
# A saved report is taken as it says: whoever wrote the file wrote its findings. The gate
|
|
965
|
+
# below compares what the file claims, so the log says that no scan stands behind it.
|
|
966
|
+
for side, source, saved in (("old", args.old, old_saved), ("new", args.new, new_saved)):
|
|
967
|
+
if saved:
|
|
968
|
+
print(f"diff: the {side} side ({_one_line(source)}) was read as a saved SkillTotal "
|
|
969
|
+
"report, not scanned; its findings are taken as given", file=sys.stderr)
|
|
952
970
|
|
|
953
971
|
if args.output:
|
|
954
972
|
try:
|
|
@@ -969,8 +987,15 @@ def _cmd_diff(args: argparse.Namespace) -> int:
|
|
|
969
987
|
if tripping:
|
|
970
988
|
first = tripping[0]
|
|
971
989
|
more = f" and {len(tripping) - 1} more" if len(tripping) > 1 else ""
|
|
972
|
-
print(f"gate: --fail-on-new {args.fail_on_new} tripped by {first['id']} "
|
|
973
|
-
f"({first['severity']}){more}", file=sys.stderr)
|
|
990
|
+
print(f"gate: --fail-on-new {args.fail_on_new} tripped by {_one_line(first['id'])} "
|
|
991
|
+
f"({_one_line(first['severity'])}){more}", file=sys.stderr)
|
|
992
|
+
return EXIT_FAIL_ON_HIGH
|
|
993
|
+
if diff.coverage_gaps_added:
|
|
994
|
+
titles = "; ".join(dict.fromkeys(_one_line(str(n.get("title", "")))
|
|
995
|
+
for n in diff.coverage_gaps_added))
|
|
996
|
+
print(f"Not analyzed in the new version only: {titles}.", file=sys.stderr)
|
|
997
|
+
if args.fail_on_unanalyzed:
|
|
998
|
+
print("gate: --fail-on-unanalyzed tripped", file=sys.stderr)
|
|
974
999
|
return EXIT_FAIL_ON_HIGH
|
|
975
1000
|
return EXIT_OK
|
|
976
1001
|
|
|
@@ -994,19 +1019,20 @@ def _new_risk(diff, level: str) -> list[dict]:
|
|
|
994
1019
|
return tripping
|
|
995
1020
|
|
|
996
1021
|
|
|
997
|
-
def _resolve_diff_side(source: str, config: Config, exclude: list[str]) -> dict:
|
|
998
|
-
"""Resolve one diff side: a saved JSON report is loaded, anything else is scanned.
|
|
1022
|
+
def _resolve_diff_side(source: str, config: Config, exclude: list[str]) -> tuple[dict, bool]:
|
|
1023
|
+
"""Resolve one diff side: a saved JSON report is loaded, anything else is scanned. Returns
|
|
1024
|
+
the report and whether it was a saved one."""
|
|
999
1025
|
from skilltotal.engine import analyze
|
|
1000
1026
|
|
|
1001
1027
|
path = Path(source)
|
|
1002
1028
|
if path.is_file() and path.suffix.lower() == ".json":
|
|
1003
1029
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1004
1030
|
if isinstance(data, dict) and {"component", "risk_score", "findings"} <= data.keys():
|
|
1005
|
-
return data
|
|
1031
|
+
return data, True
|
|
1006
1032
|
# A .json file that is not a saved report (e.g. a bare package.json) is scanned
|
|
1007
1033
|
# like any other single-file source.
|
|
1008
1034
|
report = analyze(source, ignore_rules=config.ignored_rules(), exclude=exclude)
|
|
1009
|
-
return report.to_dict()
|
|
1035
|
+
return report.to_dict(), False
|
|
1010
1036
|
|
|
1011
1037
|
|
|
1012
1038
|
def _cmd_guard(args: argparse.Namespace) -> int:
|
|
@@ -1023,7 +1049,7 @@ def _cmd_guard(args: argparse.Namespace) -> int:
|
|
|
1023
1049
|
try:
|
|
1024
1050
|
report = analyze(args.source).to_dict()
|
|
1025
1051
|
except CollectionError as exc:
|
|
1026
|
-
print(f"error: {exc}", file=sys.stderr)
|
|
1052
|
+
print(f"error: {_diagnostic(str(exc))}", file=sys.stderr)
|
|
1027
1053
|
return EXIT_ERROR
|
|
1028
1054
|
decision = evaluate(report, args.block_on)
|
|
1029
1055
|
if args.json:
|
|
@@ -1805,6 +1831,15 @@ def _one_line(text: str) -> str:
|
|
|
1805
1831
|
return one_line(text)
|
|
1806
1832
|
|
|
1807
1833
|
|
|
1834
|
+
def _diagnostic(text: str) -> str:
|
|
1835
|
+
"""An error message that carries text from outside (a git server's `remote:` lines, a
|
|
1836
|
+
registry's answer, a file name): its line breaks are kept, but control characters and CI
|
|
1837
|
+
workflow commands (`::error::`, `##[...]`) are shown as text, as in a report."""
|
|
1838
|
+
from skilltotal.report import _terminal_safe
|
|
1839
|
+
|
|
1840
|
+
return _terminal_safe(text)
|
|
1841
|
+
|
|
1842
|
+
|
|
1808
1843
|
# A quote inside one argument, then the end of it or what reads as a flag: on Windows, `\"` in a
|
|
1809
1844
|
# command line is a literal quote, so PowerShell's `'.\my comp\' --json` reaches the program as the
|
|
1810
1845
|
# single argument `.\my comp" --json`.
|
|
@@ -13,11 +13,12 @@ gating live in :mod:`skilltotal.cli`.
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
from collections import Counter
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
19
20
|
from skilltotal.baseline import evidence_fingerprint
|
|
20
|
-
from skilltotal.models import Severity
|
|
21
|
+
from skilltotal.models import UNANALYZED_TITLES, Severity
|
|
21
22
|
|
|
22
23
|
_SEVERITY_ORDER = ("critical", "high", "medium", "low")
|
|
23
24
|
|
|
@@ -42,6 +43,10 @@ class DiffReport:
|
|
|
42
43
|
# True when the two reports were produced by different rulesets: finding churn may then
|
|
43
44
|
# come from detection changes rather than component changes.
|
|
44
45
|
ruleset_mismatch: bool = False
|
|
46
|
+
# Notes of the new report that say part of it was not analyzed (models.UNANALYZED_TITLES)
|
|
47
|
+
# and that the old report did not have. A finding that disappears along with one of these
|
|
48
|
+
# may have left the scan's view, not the component.
|
|
49
|
+
coverage_gaps_added: list[dict[str, Any]] = field(default_factory=list)
|
|
45
50
|
summary: str = ""
|
|
46
51
|
|
|
47
52
|
def to_dict(self) -> dict[str, Any]:
|
|
@@ -56,6 +61,7 @@ class DiffReport:
|
|
|
56
61
|
"capabilities_added": self.capabilities_added,
|
|
57
62
|
"capabilities_removed": self.capabilities_removed,
|
|
58
63
|
"ruleset_mismatch": self.ruleset_mismatch,
|
|
64
|
+
"coverage_gaps_added": self.coverage_gaps_added,
|
|
59
65
|
"summary": self.summary,
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -96,6 +102,7 @@ def diff_reports(old: dict[str, Any], new: dict[str, Any]) -> DiffReport:
|
|
|
96
102
|
old.get("metadata", {}).get("ruleset_version")
|
|
97
103
|
!= new.get("metadata", {}).get("ruleset_version")
|
|
98
104
|
),
|
|
105
|
+
coverage_gaps_added=_coverage_gaps_added(old, new),
|
|
99
106
|
)
|
|
100
107
|
diff.summary = _summary(diff, old, new)
|
|
101
108
|
return diff
|
|
@@ -117,11 +124,12 @@ def max_new_severity(diff: DiffReport) -> Severity | None:
|
|
|
117
124
|
def _evidence_delta(
|
|
118
125
|
rule_id: str, old_f: dict[str, Any], new_f: dict[str, Any]
|
|
119
126
|
) -> dict[str, Any] | None:
|
|
120
|
-
"""Evidence-level delta for a rule present in both reports, or None if unchanged.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
127
|
+
"""Evidence-level delta for a rule present in both reports, or None if unchanged.
|
|
128
|
+
|
|
129
|
+
Occurrences are counted per fingerprint: a second copy of a line the old version already had
|
|
130
|
+
(the same snippet in the same file) is an added occurrence, not the old one again."""
|
|
131
|
+
added = _beyond(rule_id, new_f.get("evidence", []), old_f.get("evidence", []))
|
|
132
|
+
removed = _beyond(rule_id, old_f.get("evidence", []), new_f.get("evidence", []))
|
|
125
133
|
if not added and not removed:
|
|
126
134
|
return None
|
|
127
135
|
return {
|
|
@@ -134,10 +142,37 @@ def _evidence_delta(
|
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
|
|
145
|
+
def _beyond(
|
|
146
|
+
rule_id: str, side: list[dict[str, Any]], other: list[dict[str, Any]]
|
|
147
|
+
) -> list[dict[str, Any]]:
|
|
148
|
+
"""The occurrences in ``side`` beyond as many as ``other`` has of each, in fingerprint order."""
|
|
149
|
+
left = Counter(_fp(rule_id, e) for e in other)
|
|
150
|
+
extra: list[tuple[str, int, dict[str, Any]]] = []
|
|
151
|
+
for k, e in enumerate(side):
|
|
152
|
+
fp = _fp(rule_id, e)
|
|
153
|
+
if left[fp] > 0:
|
|
154
|
+
left[fp] -= 1
|
|
155
|
+
else:
|
|
156
|
+
extra.append((fp, k, e))
|
|
157
|
+
return [e for _fp_key, _k, e in sorted(extra, key=lambda item: item[:2])]
|
|
158
|
+
|
|
159
|
+
|
|
137
160
|
def _fp(rule_id: str, evidence: dict[str, Any]) -> str:
|
|
138
161
|
return evidence_fingerprint(rule_id, evidence.get("file", ""), evidence.get("snippet", ""))
|
|
139
162
|
|
|
140
163
|
|
|
164
|
+
def _coverage_gaps_added(old: dict[str, Any], new: dict[str, Any]) -> list[dict[str, Any]]:
|
|
165
|
+
"""The new report's not-analyzed notes that the old report has no copy of (the same title,
|
|
166
|
+
file and reason)."""
|
|
167
|
+
|
|
168
|
+
def gaps(report: dict[str, Any]) -> list[dict[str, Any]]:
|
|
169
|
+
return [n for n in report.get("needs_review") or []
|
|
170
|
+
if isinstance(n, dict) and n.get("title") in UNANALYZED_TITLES]
|
|
171
|
+
|
|
172
|
+
seen = {(n.get("title"), n.get("file"), n.get("reason")) for n in gaps(old)}
|
|
173
|
+
return [n for n in gaps(new) if (n.get("title"), n.get("file"), n.get("reason")) not in seen]
|
|
174
|
+
|
|
175
|
+
|
|
141
176
|
def _side_summary(report: dict[str, Any]) -> dict[str, Any]:
|
|
142
177
|
component = report.get("component", {})
|
|
143
178
|
metadata = report.get("metadata", {})
|
|
@@ -175,4 +210,6 @@ def _summary(diff: DiffReport, old: dict[str, Any], new: dict[str, Any]) -> str:
|
|
|
175
210
|
parts.append(f"{len(diff.new_findings)} new finding(s)")
|
|
176
211
|
parts.append(f"{len(diff.resolved_findings)} resolved")
|
|
177
212
|
parts.append(f"{len(diff.changed_findings)} changed")
|
|
213
|
+
if diff.coverage_gaps_added:
|
|
214
|
+
parts.append(f"{len(diff.coverage_gaps_added)} new coverage gap(s)")
|
|
178
215
|
return ": ".join([parts[0], ", ".join(parts[1:])])
|
|
@@ -144,7 +144,8 @@ def analyze_directory(
|
|
|
144
144
|
# must be scanned there; in a repository they duplicate source that is checked in beside them.
|
|
145
145
|
published = component.type in _PUBLISHED_PACKAGE_TYPES
|
|
146
146
|
# What npm runs on install is read whatever its shape, minified or not: it is the attack.
|
|
147
|
-
|
|
147
|
+
install_limits: list[str] = []
|
|
148
|
+
install_scripts = _install_script_files(Path(root), install_limits)
|
|
148
149
|
# So is the code the package runs when it is imported or its command is run: a main file padded
|
|
149
150
|
# with one long comment until it looked minified was skipped, and its payload with it.
|
|
150
151
|
index = FileIndex.build(
|
|
@@ -161,6 +162,18 @@ def analyze_directory(
|
|
|
161
162
|
for relpath, script in install_scripts.items():
|
|
162
163
|
if relpath not in indexed:
|
|
163
164
|
needs_review.append(_unanalyzed_install_script(Path(root), relpath, script))
|
|
165
|
+
if install_limits:
|
|
166
|
+
# The walk of what install scripts run stopped at a limit: what lies past it runs at
|
|
167
|
+
# install time and was not followed. The install guard keys on this title.
|
|
168
|
+
needs_review.append(NeedsReview(
|
|
169
|
+
category="coverage",
|
|
170
|
+
title=UNANALYZED_INSTALL_SCRIPT,
|
|
171
|
+
reason=(
|
|
172
|
+
"Reading what package.json runs at install time stopped at a limit ("
|
|
173
|
+
+ "; ".join(install_limits) + "). Code past it runs at install time and may "
|
|
174
|
+
"not have been analyzed, so read the install scripts before installing."
|
|
175
|
+
),
|
|
176
|
+
))
|
|
164
177
|
if index.minified:
|
|
165
178
|
shown = ", ".join(index.minified[:5])
|
|
166
179
|
more = f" (+{len(index.minified) - 5} more)" if len(index.minified) > 5 else ""
|
|
@@ -421,9 +434,12 @@ _UNANALYZED_LANGUAGES = {
|
|
|
421
434
|
_NPM_INSTALL_SCRIPTS = ("preinstall", "install", "postinstall")
|
|
422
435
|
|
|
423
436
|
|
|
424
|
-
def _install_script_files(root: Path) -> dict[str, str]:
|
|
437
|
+
def _install_script_files(root: Path, limits: list[str] | None = None) -> dict[str, str]:
|
|
425
438
|
"""Files ``package.json`` runs at install time, as ``{relpath: script name}``.
|
|
426
439
|
|
|
440
|
+
Each bound the walk stops at is appended to ``limits`` (when given), in words: what lies past
|
|
441
|
+
it was not followed, and the report must not call the install clean.
|
|
442
|
+
|
|
427
443
|
Any word of a ``preinstall`` / ``install`` / ``postinstall`` command that names a file inside
|
|
428
444
|
the component is that file (``node setup.js``, ``sh ./scripts/x.sh``, ``./bin/fetch``), found
|
|
429
445
|
the way Node finds it (``node .`` runs the package's ``main``, ``node install`` runs
|
|
@@ -440,7 +456,11 @@ def _install_script_files(root: Path) -> dict[str, str]:
|
|
|
440
456
|
"""
|
|
441
457
|
base = root.resolve()
|
|
442
458
|
queue: list[tuple[Path, str]] = []
|
|
443
|
-
|
|
459
|
+
reached: list[str] = []
|
|
460
|
+
manifests = _package_manifests(root)
|
|
461
|
+
if len(manifests) > _MAX_PACKAGES:
|
|
462
|
+
reached.append(f"{_MAX_PACKAGES} packages inside the component")
|
|
463
|
+
for manifest in manifests:
|
|
444
464
|
try:
|
|
445
465
|
data = bounded_json.loads(manifest.read_text(encoding="utf-8", errors="replace"))
|
|
446
466
|
except (OSError, ValueError):
|
|
@@ -453,12 +473,19 @@ def _install_script_files(root: Path) -> dict[str, str]:
|
|
|
453
473
|
command = scripts.get(script)
|
|
454
474
|
if not isinstance(command, str):
|
|
455
475
|
continue
|
|
456
|
-
|
|
476
|
+
words = _install_command_words(package, command)
|
|
477
|
+
if len(words) >= _MAX_INSTALL_WORDS:
|
|
478
|
+
reached.append(f"{_MAX_INSTALL_WORDS} words of the {script} command")
|
|
479
|
+
for word in words:
|
|
457
480
|
target = _module_file(base, package, word)
|
|
458
481
|
if target is not None:
|
|
459
482
|
queue.append((target, script))
|
|
460
483
|
found: dict[str, str] = {}
|
|
461
|
-
while queue
|
|
484
|
+
while queue:
|
|
485
|
+
if len(found) >= _MAX_INSTALL_FILES:
|
|
486
|
+
if any(target.relative_to(base).as_posix() not in found for target, _ in queue):
|
|
487
|
+
reached.append(f"{_MAX_INSTALL_FILES} files")
|
|
488
|
+
break
|
|
462
489
|
target, script = queue.pop(0)
|
|
463
490
|
relpath = target.relative_to(base).as_posix()
|
|
464
491
|
if relpath in found:
|
|
@@ -480,6 +507,8 @@ def _install_script_files(root: Path) -> dict[str, str]:
|
|
|
480
507
|
loaded = _bundled_module_file(base, target.parent, match.group(2))
|
|
481
508
|
if loaded is not None:
|
|
482
509
|
queue.append((loaded, script))
|
|
510
|
+
if limits is not None:
|
|
511
|
+
limits.extend(dict.fromkeys(reached))
|
|
483
512
|
return found
|
|
484
513
|
|
|
485
514
|
|
|
@@ -516,9 +545,11 @@ def _install_command_words(package: Path, command: str) -> list[str]:
|
|
|
516
545
|
_MAX_INSTALL_WORDS = 64
|
|
517
546
|
# An install-time dropper is a handful of files; this only bounds a pathological require graph.
|
|
518
547
|
_MAX_INSTALL_FILES = 200
|
|
519
|
-
# Packages inside a component: how deep and how many are read
|
|
548
|
+
# Packages inside a component: how deep and how many are read. A large monorepo has a few
|
|
549
|
+
# hundred; a package.json is small, so the count only bounds a pathological tree. Reaching it
|
|
550
|
+
# makes the install guard ask, so it must sit above what real repositories hold.
|
|
520
551
|
_MAX_PACKAGE_DEPTH = 6
|
|
521
|
-
_MAX_PACKAGES =
|
|
552
|
+
_MAX_PACKAGES = 2000
|
|
522
553
|
|
|
523
554
|
|
|
524
555
|
# How many entry points of the component's own package.json are read whatever their shape.
|
|
@@ -646,7 +677,8 @@ def _skipped_folders_note(index: FileIndex) -> NeedsReview | None:
|
|
|
646
677
|
reason=(
|
|
647
678
|
f"{len(folders)} folder(s) were not read: {shown}{more}. They hold third-party code "
|
|
648
679
|
"or a build of the component's own. Code the component loads from them by a literal "
|
|
649
|
-
"path was read and checked;
|
|
680
|
+
"path was read and checked; packages it loads by name (a bundled dependency) and "
|
|
681
|
+
"everything else in them were not."
|
|
650
682
|
),
|
|
651
683
|
file=f"{folders[0][0]}/",
|
|
652
684
|
)
|
|
@@ -661,9 +693,10 @@ def _unloaded_note(unloaded: list[str]) -> NeedsReview:
|
|
|
661
693
|
title=UNANALYZED_LOADED_CODE,
|
|
662
694
|
reason=(
|
|
663
695
|
f"The component loads {len(unloaded)} file(s) from a folder the scan otherwise skips, "
|
|
664
|
-
f"and they
|
|
665
|
-
f"unreadable,
|
|
666
|
-
"runs and nothing in it
|
|
696
|
+
f"and they were not read (a native binary, larger than "
|
|
697
|
+
f"{MAX_FILE_BYTES // (1024 * 1024)} MiB, unreadable, excluded from the scan, or past "
|
|
698
|
+
f"the {MAX_LOADED_FILES}-file limit): {shown}{more}. That code runs and nothing in it "
|
|
699
|
+
"was checked."
|
|
667
700
|
),
|
|
668
701
|
file=unloaded[0],
|
|
669
702
|
)
|