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.
Files changed (76) hide show
  1. {skilltotal-0.76.0/skilltotal.egg-info → skilltotal-0.78.0}/PKG-INFO +7 -5
  2. {skilltotal-0.76.0 → skilltotal-0.78.0}/README.md +6 -4
  3. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/__init__.py +2 -2
  4. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/agent_hook.py +8 -2
  5. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/autorun.py +27 -8
  6. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/cli.py +56 -21
  7. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/diff.py +43 -6
  8. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/engine.py +44 -11
  9. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/file_index.py +5 -1
  10. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/loaded_code.py +10 -5
  11. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/mcp_server.py +25 -7
  12. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/models.py +2 -0
  13. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/report.py +22 -9
  14. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/agent_approval.py +48 -13
  15. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/agent_config.py +6 -2
  16. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/dependency_redirect.py +43 -1
  17. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/mcp.py +9 -3
  18. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/obfuscation.py +18 -3
  19. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/pth_exec.py +4 -0
  20. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/secret_exfil.py +58 -14
  21. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/sensitive_paths.py +18 -3
  22. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/shell_script.py +22 -1
  23. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scoring.py +9 -11
  24. {skilltotal-0.76.0 → skilltotal-0.78.0/skilltotal.egg-info}/PKG-INFO +7 -5
  25. {skilltotal-0.76.0 → skilltotal-0.78.0}/LICENSE +0 -0
  26. {skilltotal-0.76.0 → skilltotal-0.78.0}/MANIFEST.in +0 -0
  27. {skilltotal-0.76.0 → skilltotal-0.78.0}/pyproject.toml +0 -0
  28. {skilltotal-0.76.0 → skilltotal-0.78.0}/setup.cfg +0 -0
  29. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/__main__.py +0 -0
  30. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/agent_skill.py +0 -0
  31. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/baseline.py +0 -0
  32. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/bounded_json.py +0 -0
  33. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/capabilities.py +0 -0
  34. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/collector.py +0 -0
  35. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/combinations.py +0 -0
  36. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/concat_normalize.py +0 -0
  37. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/config.py +0 -0
  38. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/guard.py +0 -0
  39. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/hook_scan.py +0 -0
  40. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/inventory.py +0 -0
  41. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/owasp.py +0 -0
  42. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/parallel.py +0 -0
  43. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/provenance.py +0 -0
  44. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/rules.py +0 -0
  45. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/sarif.py +0 -0
  46. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/sbom.py +0 -0
  47. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/__init__.py +0 -0
  48. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/base.py +0 -0
  49. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/clipper.py +0 -0
  50. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/dynamic_code.py +0 -0
  51. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/email_exfil.py +0 -0
  52. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/encrypted_archive.py +0 -0
  53. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/exposure.py +0 -0
  54. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/filesystem.py +0 -0
  55. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/install_scripts.py +0 -0
  56. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/invisible_unicode.py +0 -0
  57. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/mcp_elicitation.py +0 -0
  58. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/mcp_pinning.py +0 -0
  59. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/network.py +0 -0
  60. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/npm_supply.py +0 -0
  61. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/oauth_auth.py +0 -0
  62. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/prompt_surface.py +0 -0
  63. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/python_ast.py +0 -0
  64. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/scoped_identity.py +0 -0
  65. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/secrets.py +0 -0
  66. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/shell_evasion.py +0 -0
  67. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/shell_exec.py +0 -0
  68. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/scanners/skill_fetch_exec.py +0 -0
  69. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/text_normalize.py +0 -0
  70. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/traits.py +0 -0
  71. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal/typosquatting.py +0 -0
  72. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/SOURCES.txt +0 -0
  73. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/dependency_links.txt +0 -0
  74. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/entry_points.txt +0 -0
  75. {skilltotal-0.76.0 → skilltotal-0.78.0}/skilltotal.egg-info/requires.txt +0 -0
  76. {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.76.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.76.0
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.76.0
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.76.0 # in a virtual environment outside the checkout
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.76.0 -- skilltotal scan https://github.com/owner/repo
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.76.0
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.76.0
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.76.0 # in a virtual environment outside the checkout
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.76.0 -- skilltotal scan https://github.com/owner/repo
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.76.0"
25
+ __version__ = "0.78.0"
26
26
  ENGINE_VERSION = __version__
27
27
  REPORT_SCHEMA_VERSION = "1.9"
28
- RULESET_VERSION = 78
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
- _COMPUTED = re.compile(r"[$`{}]")
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 '"mcpServers"' in ref.text:
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] [--no-config]
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 capability changes.
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}: {exc}", file=sys.stderr)
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 private "
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
- old_fps = {_fp(rule_id, e): e for e in old_f.get("evidence", [])}
122
- new_fps = {_fp(rule_id, e): e for e in new_f.get("evidence", [])}
123
- added = [new_fps[k] for k in sorted(new_fps.keys() - old_fps.keys())]
124
- removed = [old_fps[k] for k in sorted(old_fps.keys() - new_fps.keys())]
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
- install_scripts = _install_script_files(Path(root))
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
- for manifest in _package_manifests(root):
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
- for word in _install_command_words(package, command):
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 and len(found) < _MAX_INSTALL_FILES:
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 (a monorepo has tens).
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 = 100
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; nothing else in them was."
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 could not be read (larger than {MAX_FILE_BYTES // (1024 * 1024)} MiB, "
665
- f"unreadable, or past the {MAX_LOADED_FILES}-file limit): {shown}{more}. That code "
666
- "runs and nothing in it was checked."
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
  )