mpak-scanner 0.2.2__tar.gz → 0.2.4__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 (81) hide show
  1. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/.gitignore +3 -0
  2. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/CLAUDE.md +36 -11
  3. mpak_scanner-0.2.4/Dockerfile +26 -0
  4. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/PKG-INFO +1 -1
  5. mpak_scanner-0.2.4/SCANNER_BUG_REPORT_NODE_AI05.md +157 -0
  6. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/pyproject.toml +4 -1
  7. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/__init__.py +1 -1
  8. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/artifact_integrity/ai01_manifest.py +1 -1
  9. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/artifact_integrity/ai05_bundle_completeness.py +25 -2
  10. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/supply_chain/sc02_vuln_scan.py +5 -0
  11. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/models.py +1 -0
  12. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/scanner.py +1 -1
  13. mpak_scanner-0.2.4/tests/test_e2e_bundles.py +144 -0
  14. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/test_scanner.py +87 -0
  15. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/uv.lock +1 -1
  16. mpak_scanner-0.2.2/Dockerfile +0 -4
  17. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/Makefile +0 -0
  18. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/README.md +0 -0
  19. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/docs/CONTRIBUTING.md +0 -0
  20. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/docs/CONTROLS.md +0 -0
  21. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/docs/LEVELS.md +0 -0
  22. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/cli.py +0 -0
  23. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/__init__.py +0 -0
  24. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/artifact_integrity/__init__.py +0 -0
  25. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/artifact_integrity/ai02_content_hashes.py +0 -0
  26. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/artifact_integrity/ai03_bundle_signature.py +0 -0
  27. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/artifact_integrity/ai04_reproducible_build.py +0 -0
  28. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/base.py +0 -0
  29. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/capability_declaration/__init__.py +0 -0
  30. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/capability_declaration/cd01_tools.py +0 -0
  31. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/capability_declaration/cd02_permission_scope.py +0 -0
  32. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/capability_declaration/cd03_tool_description_safety.py +0 -0
  33. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/capability_declaration/cd04_credential_scope.py +0 -0
  34. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/capability_declaration/cd05_token_lifetime.py +0 -0
  35. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/code_quality/__init__.py +0 -0
  36. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/code_quality/cq01_secrets.py +0 -0
  37. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/code_quality/cq02_malicious.py +0 -0
  38. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/code_quality/cq03_static_analysis.py +0 -0
  39. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/code_quality/cq04_input_validation.py +0 -0
  40. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/code_quality/cq05_safe_execution.py +0 -0
  41. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/code_quality/cq06_behavioral_analysis.py +0 -0
  42. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/provenance/__init__.py +0 -0
  43. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/provenance/pr01_source_repository.py +0 -0
  44. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/provenance/pr02_author_identity.py +0 -0
  45. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/provenance/pr03_build_attestation.py +0 -0
  46. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/provenance/pr04_commit_linkage.py +0 -0
  47. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/provenance/pr05_repository_health.py +0 -0
  48. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/supply_chain/__init__.py +0 -0
  49. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/supply_chain/sc01_sbom.py +0 -0
  50. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/supply_chain/sc03_dependency_pinning.py +0 -0
  51. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/supply_chain/sc04_lockfile_integrity.py +0 -0
  52. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/controls/supply_chain/sc05_trusted_sources.py +0 -0
  53. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/job.py +0 -0
  54. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/rules/__init__.py +0 -0
  55. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/rules/malicious.yaml +0 -0
  56. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/rules/secrets.yaml +0 -0
  57. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/rules/unsafe-exec.yaml +0 -0
  58. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/schemas.py +0 -0
  59. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/utils/__init__.py +0 -0
  60. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/src/mpak_scanner/utils/name_analysis.py +0 -0
  61. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/__init__.py +0 -0
  62. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/README.md +0 -0
  63. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/clean-l1-bundle/manifest.json +0 -0
  64. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/clean-l1-bundle/src/server.py +0 -0
  65. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/has-secrets-bundle/manifest.json +0 -0
  66. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/has-secrets-bundle/src/server.py +0 -0
  67. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/has-vulns-bundle/manifest.json +0 -0
  68. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/has-vulns-bundle/requirements.txt +0 -0
  69. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/has-vulns-bundle/src/server.py +0 -0
  70. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/invalid-manifest-bundle/manifest.json +0 -0
  71. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/invalid-manifest-bundle/src/server.py +0 -0
  72. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/malicious-test-bundle/manifest.json +0 -0
  73. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/malicious-test-bundle/src/malicious_server/__init__.py +0 -0
  74. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/malicious-test-bundle/src/malicious_server/server.py +0 -0
  75. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/malicious-test-bundle/src/malicious_server/setup_hook.py +0 -0
  76. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/missing-tools-bundle/manifest.json +0 -0
  77. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/missing-tools-bundle/src/server.py +0 -0
  78. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/node-server-bundle/manifest.json +0 -0
  79. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/node-server-bundle/src/server.js +0 -0
  80. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/unsafe-node-bundle/manifest.json +0 -0
  81. {mpak_scanner-0.2.2 → mpak_scanner-0.2.4}/tests/fixtures/unsafe-node-bundle/src/server.js +0 -0
@@ -51,6 +51,9 @@ dmypy.json
51
51
  *.mcpb
52
52
  /tmp/
53
53
 
54
+ # Downloaded test bundles (e2e tests)
55
+ /tests/data/
56
+
54
57
  # Environment
55
58
  .env
56
59
  .env.*
@@ -62,7 +62,9 @@ src/mpak_scanner/
62
62
  └── capability_declaration/ # CD-01, CD-02, CD-03
63
63
 
64
64
  tests/
65
- ├── test_scanner.py # All tests
65
+ ├── test_scanner.py # Unit + fixture-based integration tests
66
+ ├── test_e2e_bundles.py # E2E tests against real registry bundles
67
+ ├── data/ # Downloaded bundles for e2e tests (gitignored)
66
68
  └── fixtures/ # Test bundles (see fixtures/README.md)
67
69
  ├── clean-l1-bundle/ # Passes Level 1
68
70
  ├── has-secrets-bundle/ # Fails CQ-01
@@ -128,35 +130,58 @@ Located in `tests/fixtures/`. Each fixture tests specific controls:
128
130
  - `node-server-bundle/` - Clean Node.js bundle, passes CQ-05
129
131
  - `unsafe-node-bundle/` - Contains unsafe patterns (CQ-05 should fail)
130
132
 
133
+ ## E2E Tests
134
+
135
+ End-to-end tests scan real bundles pulled from the mpak registry. They catch false positives and regressions that synthetic fixtures miss.
136
+
137
+ ### Setup (one-time)
138
+
139
+ ```bash
140
+ mpak bundle pull @nimblebraininc/finnhub -o tests/data/finnhub.mcpb
141
+ mpak bundle pull @nimblebraininc/folk -o tests/data/folk.mcpb
142
+ mpak bundle pull @nimblebraininc/nationalparks -o tests/data/nationalparks.mcpb
143
+ ```
144
+
145
+ Bundles are stored in `tests/data/` (gitignored). Tests skip gracefully if bundles are missing.
146
+
147
+ ### Running
148
+
149
+ ```bash
150
+ uv run pytest -m e2e -v # e2e tests only
151
+ uv run pytest tests/test_scanner.py # unit tests only (fast)
152
+ uv run pytest # all tests (unit + e2e)
153
+ ```
154
+
155
+ ### Adding new e2e bundles
156
+
157
+ 1. `mpak bundle pull @scope/name -o tests/data/name.mcpb`
158
+ 2. Add a `pytest.param()` entry to the appropriate list in `test_e2e_bundles.py`
159
+
131
160
  ## Releasing a New Version
132
161
 
133
162
  The scanner is distributed via PyPI. The Docker image installs from PyPI, not local source.
134
163
 
135
164
  ### Steps
136
165
 
137
- 1. **Bump version** in three files (must match):
166
+ 1. **Bump version** in four files (must all match):
138
167
  - `pyproject.toml` (`version = "X.Y.Z"`)
139
168
  - `src/mpak_scanner/__init__.py` (`__version__ = "X.Y.Z"`)
140
169
  - `src/mpak_scanner/scanner.py` (`SCANNER_VERSION = "X.Y.Z"`)
170
+ - `Dockerfile` (`mpak-scanner[job]==X.Y.Z`)
141
171
 
142
- 2. **Update Dockerfile** version pin:
143
- ```dockerfile
144
- RUN pip install --no-cache-dir mpak-scanner==X.Y.Z
145
- ```
146
-
147
- 3. **Run verification**:
172
+ 2. **Run verification**:
148
173
  ```bash
149
174
  uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run ty check src/ && uv run pytest
150
175
  ```
151
176
 
152
- 4. **Commit and push** in `apps/mpak`
177
+ 3. **Commit and push** in `apps/mpak`
153
178
 
154
- 5. **Publish to PyPI**:
179
+ 4. **Publish to PyPI** (from `apps/mpak/apps/scanner/`):
155
180
  ```bash
156
181
  uv build && uv publish
157
182
  ```
158
183
 
159
- 6. **Deploy Docker image** (from `hq/deployments/mpak/`):
184
+ 5. **Build + push Docker image** (from `hq/deployments/mpak/`):
160
185
  ```bash
161
186
  make deploy-scanner ENV=production
162
187
  make apply-scanner-infra ENV=production # only if RBAC/secrets changed
@@ -0,0 +1,26 @@
1
+ FROM python:3.13-slim
2
+
3
+ # System deps for external security tools
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ curl \
6
+ ca-certificates \
7
+ git \
8
+ nodejs \
9
+ npm \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Anchore tools: syft (SBOM), grype (CVE scanning)
13
+ RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin && \
14
+ curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
15
+
16
+ # TruffleHog (secret detection)
17
+ RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
18
+
19
+ # ESLint with security plugin (JS/TS static analysis)
20
+ RUN npm install -g eslint eslint-plugin-security --no-fund --no-audit
21
+
22
+ # mpak-scanner + Python security tools (bandit, guarddog)
23
+ RUN pip install --no-cache-dir "mpak-scanner[job]==0.2.4" bandit guarddog
24
+
25
+ ENTRYPOINT ["mpak-scanner"]
26
+ CMD ["job"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpak-scanner
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Security scanner for MCP bundles. Powers mpak Certified verification.
5
5
  Project-URL: Homepage, https://mpak.dev
6
6
  Project-URL: Documentation, https://mpak.dev/certified
@@ -0,0 +1,157 @@
1
+ # mpak-scanner: AI-05 false positives on Node.js bundles
2
+
3
+ ## Summary
4
+
5
+ AI-05 (Bundle Completeness) produces false-positive HIGH findings on every compiled JavaScript file in a Node.js bundle's output directory (`build/`, `dist/`). The control has Python-specific module resolution but no equivalent for Node.js, causing all non-entry-point `.js` files to be flagged as "unexpected executables." This blocks Node.js bundles from reaching L2 compliance.
6
+
7
+ **Scanner version**: 0.2.3
8
+ **Bundle tested**: `@nimblebraininc/nationalparks@0.2.0` (Node.js, TypeScript)
9
+
10
+ ## Reproduction
11
+
12
+ ```bash
13
+ # Build a Node.js MCPB bundle (any multi-file TypeScript server)
14
+ cd mcp-servers/nationalparks
15
+ npm ci && npm run build && npm prune --omit=dev
16
+ mcpb pack . test.mcpb
17
+
18
+ # Scan it
19
+ uvx mpak-scanner scan test.mcpb --json
20
+ ```
21
+
22
+ **Result**: AI-05 FAIL with 11 HIGH findings. Every `.js` file in `build/` except the entry point is flagged:
23
+
24
+ ```
25
+ [HIGH] Unexpected executable: build/constants.js
26
+ [HIGH] Unexpected executable: build/formatters.js
27
+ [HIGH] Unexpected executable: build/config.js
28
+ [HIGH] Unexpected executable: build/schemas.js
29
+ [HIGH] Unexpected executable: build/utils/npsApiClient.js
30
+ [HIGH] Unexpected executable: build/handlers/getAlerts.js
31
+ [HIGH] Unexpected executable: build/handlers/getParkDetails.js
32
+ [HIGH] Unexpected executable: build/handlers/getEvents.js
33
+ [HIGH] Unexpected executable: build/handlers/findParks.js
34
+ [HIGH] Unexpected executable: build/handlers/getCampgrounds.js
35
+ [HIGH] Unexpected executable: build/handlers/getVisitorCenters.js
36
+ ```
37
+
38
+ **Expected**: AI-05 PASS. These files are standard Node.js modules imported by the entry point (`build/index.js`).
39
+
40
+ ## Root cause
41
+
42
+ `ai05_bundle_completeness.py` builds a "referenced files" set from the manifest, then flags any executable-extension file not in that set.
43
+
44
+ For **Python**, `_add_python_module_files()` (line 235) resolves `python -m mcp_echo.server` into the entire `mcp_echo/` package directory tree, adding all files as referenced. This correctly covers Python's module import pattern.
45
+
46
+ For **Node.js**, there is no equivalent. The referenced set only contains:
47
+ - `server.entry_point` (e.g., `build/index.js`) -- added at line 198
48
+ - `mcp_config.args` file-like values (e.g., `${__dirname}/build/index.js` cleaned to `build/index.js`) -- added at line 227
49
+
50
+ But `build/index.js` imports sibling modules (`./config.js`, `./schemas.js`, `./handlers/findParks.js`, etc.) via standard ES module `import` statements. These are never added to the referenced set.
51
+
52
+ ### Code path
53
+
54
+ ```
55
+ _build_referenced_files()
56
+ ├── server.entry_point → "build/index.js" ✓ added
57
+ ├── mcp_config.args → "${__dirname}/build/index.js" ✓ added (duplicate)
58
+ ├── Python -m detection → _add_python_module_files() ✓ adds whole package
59
+ └── Node.js entry_point → (nothing) ✗ missing
60
+ ```
61
+
62
+ ### Why node_modules isn't affected
63
+
64
+ `node_modules/` is correctly allowed via `DEP_DIRS` on line 42 / `_is_in_dep_dir()` on line 296. The problem is limited to the server's own compiled output directory.
65
+
66
+ ## Proposed fix
67
+
68
+ Add `_add_node_entry_point_files()` analogous to `_add_python_module_files()`:
69
+
70
+ ```python
71
+ def _add_node_entry_point_files(
72
+ self, entry_point: str, bundle_dir: Path, referenced: set[str]
73
+ ) -> None:
74
+ """Resolve a Node.js entry point to its sibling modules.
75
+
76
+ TypeScript compiles to a directory (build/, dist/) where the entry
77
+ point imports other .js files. Add all files in the entry point's
78
+ directory tree as referenced.
79
+ """
80
+ entry_path = bundle_dir / entry_point
81
+ if not entry_path.exists():
82
+ return
83
+
84
+ # Add all files in the entry point's parent directory tree
85
+ entry_dir = entry_path.parent
86
+ if entry_dir == bundle_dir:
87
+ # Entry point is at root; don't add everything
88
+ return
89
+
90
+ for f in entry_dir.rglob("*"):
91
+ if f.is_file():
92
+ referenced.add(str(f.relative_to(bundle_dir)).replace("\\", "/"))
93
+ ```
94
+
95
+ Then call it from `_build_referenced_files()` when `server.type == "node"`:
96
+
97
+ ```python
98
+ # After the entry_point handling (around line 198):
99
+ server_type = server.get("type", "")
100
+ if server_type == "node" and entry_point and bundle_dir:
101
+ self._add_node_entry_point_files(entry_point, bundle_dir, referenced)
102
+ ```
103
+
104
+ ### Why directory-tree resolution is correct
105
+
106
+ This mirrors the Python approach:
107
+ - **Python**: `-m mcp_echo.server` resolves to the `mcp_echo/` package, and all files in that package are added (line 251: `package_dir.rglob("*")`)
108
+ - **Node.js**: `build/index.js` resolves to the `build/` directory, and all files in that directory are added
109
+
110
+ Both languages compile/organize source into a directory where the entry point's siblings are expected runtime dependencies.
111
+
112
+ ### Edge case: root-level entry point
113
+
114
+ If `entry_point` is `index.js` (at bundle root), we should NOT add every file in the bundle. The proposed code handles this with the `entry_dir == bundle_dir` guard.
115
+
116
+ ## Test fixture gap
117
+
118
+ The existing `node-server-bundle` fixture only has a single file (`src/server.js`):
119
+
120
+ ```
121
+ node-server-bundle/
122
+ ├── manifest.json
123
+ └── src/server.js
124
+ ```
125
+
126
+ This doesn't represent real Node.js bundles which have multi-file compiled output. A realistic fixture would look like:
127
+
128
+ ```
129
+ node-server-bundle/
130
+ ├── manifest.json
131
+ ├── package.json
132
+ ├── build/
133
+ │ ├── index.js # entry_point
134
+ │ ├── config.js # imported by index.js
135
+ │ └── handlers/
136
+ │ └── handler.js # imported by index.js
137
+ └── node_modules/
138
+ └── some-dep/
139
+ ├── package.json
140
+ └── index.js
141
+ ```
142
+
143
+ ### Suggested test cases
144
+
145
+ 1. **Multi-file Node.js build** (should PASS): Entry point at `build/index.js` with sibling modules in `build/`
146
+ 2. **Node.js with stray script** (should FAIL): Entry point in `build/` plus an unrelated `scripts/deploy.sh` at root
147
+ 3. **Root-level entry point** (should PASS): Entry point at `index.js` with no extra files outside node_modules
148
+
149
+ ## Impact
150
+
151
+ - Blocks all multi-file Node.js servers from L2 compliance
152
+ - `@nimblebraininc/nationalparks` is the first Node.js server going through MCPB migration
153
+ - All 15 other L2 controls pass; AI-05 is the sole blocker
154
+
155
+ ## Related: mcpb CLI excludes lockfiles
156
+
157
+ Separate issue, but noted here for context: `mcpb pack` v2.1.2 hardcodes `package-lock.json` in its `EXCLUDE_PATTERNS` (`dist/node/files.js`). This prevents lockfiles from appearing in Node.js bundles, which causes SC-03/SC-04 INFO findings about missing lockfiles. SC-03 and SC-04 still PASS (SC-03 falls back to package.json parsing; SC-04 is client-enforced), but the lockfile should ideally be includable. This is an `mcpb` CLI issue, not a scanner issue.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mpak-scanner"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Security scanner for MCP bundles. Powers mpak Certified verification."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -92,6 +92,9 @@ python-version = "3.13"
92
92
 
93
93
  [tool.pytest.ini_options]
94
94
  testpaths = ["tests"]
95
+ markers = [
96
+ "e2e: end-to-end tests against real bundles from the registry (requires tests/data/)",
97
+ ]
95
98
 
96
99
  [dependency-groups]
97
100
  dev = [
@@ -3,5 +3,5 @@
3
3
  from mpak_scanner.models import ComplianceLevel, ControlResult, SecurityReport
4
4
  from mpak_scanner.scanner import scan_bundle
5
5
 
6
- __version__ = "0.2.2"
6
+ __version__ = "0.2.4"
7
7
  __all__ = ["scan_bundle", "SecurityReport", "ControlResult", "ComplianceLevel"]
@@ -118,7 +118,7 @@ class AI01ValidManifest(Control):
118
118
  else:
119
119
  # Validate MTF extension structure
120
120
  mtf_schema = get_mtf_schema()
121
- mtf_errors = self._validate_schema({"_meta": manifest.get("_meta", {})}, mtf_schema, "MTF")
121
+ mtf_errors = self._validate_schema(mtf_namespace, mtf_schema, "MTF")
122
122
  for error in mtf_errors:
123
123
  findings.append(
124
124
  Finding(
@@ -227,8 +227,10 @@ class AI05BundleCompleteness(Control):
227
227
  cleaned = arg.replace("${__dirname}/", "")
228
228
  referenced.add(cleaned)
229
229
 
230
- # _meta.org.mpaktrust metadata (non-executable, always allowed)
231
- # These don't need to be in the referenced set since they're not executable
230
+ # Node.js entry point directory resolution
231
+ server_type = server.get("type", "") if isinstance(server, dict) else ""
232
+ if server_type == "node" and entry_point and bundle_dir:
233
+ self._add_node_entry_point_files(entry_point, bundle_dir, referenced)
232
234
 
233
235
  return referenced
234
236
 
@@ -252,6 +254,27 @@ class AI05BundleCompleteness(Control):
252
254
  if f.is_file():
253
255
  referenced.add(str(f.relative_to(bundle_dir)).replace("\\", "/"))
254
256
 
257
+ def _add_node_entry_point_files(self, entry_point: str, bundle_dir: Path, referenced: set[str]) -> None:
258
+ """Resolve a Node.js entry point to its sibling modules.
259
+
260
+ TypeScript compiles to a directory (build/, dist/) where the entry
261
+ point imports other .js files. Add all files in the entry point's
262
+ directory tree as referenced.
263
+ """
264
+ entry_path = bundle_dir / entry_point
265
+ if not entry_path.exists():
266
+ return
267
+
268
+ # Add all files in the entry point's parent directory tree
269
+ entry_dir = entry_path.parent
270
+ if entry_dir == bundle_dir:
271
+ # Entry point is at root; don't add everything
272
+ return
273
+
274
+ for f in entry_dir.rglob("*"):
275
+ if f.is_file():
276
+ referenced.add(str(f.relative_to(bundle_dir)).replace("\\", "/"))
277
+
255
278
  def _looks_like_file(self, arg: str) -> bool:
256
279
  """Check if an argument looks like a file path."""
257
280
  # Skip flags
@@ -12,6 +12,7 @@ Blocking logic per MTF spec:
12
12
  """
13
13
 
14
14
  import json
15
+ import logging
15
16
  import subprocess
16
17
  import time
17
18
  import urllib.request
@@ -21,6 +22,8 @@ from typing import Any
21
22
  from mpak_scanner.controls.base import Control, ControlRegistry
22
23
  from mpak_scanner.models import ControlResult, ControlStatus, Finding, Severity
23
24
 
25
+ logger = logging.getLogger(__name__)
26
+
24
27
  # EPSS API endpoint (FIRST.org)
25
28
  EPSS_API_URL = "https://api.first.org/data/v1/epss"
26
29
 
@@ -150,6 +153,8 @@ class SC02VulnerabilityScan(Control):
150
153
  return self.error("Vulnerability scan timed out")
151
154
 
152
155
  # Grype exits non-zero if it finds vulnerabilities, but also for errors
156
+ stderr_preview = result.stderr[:200] if result.stderr else ""
157
+ logger.info("grype exit=%d stdout=%d bytes stderr=%s", result.returncode, len(result.stdout), stderr_preview)
153
158
  if result.returncode != 0 and "no packages discovered" not in result.stderr.lower():
154
159
  if not result.stdout.strip():
155
160
  return ControlResult(
@@ -324,6 +324,7 @@ class SecurityReport:
324
324
  }
325
325
  for f in ctrl.findings
326
326
  ],
327
+ **({"error": ctrl.error} if ctrl.error else {}),
327
328
  }
328
329
  for ctrl_id, ctrl in domain.controls.items()
329
330
  }
@@ -70,7 +70,7 @@ __all__ = ["scan_bundle", "extract_bundle"]
70
70
  logger = logging.getLogger(__name__)
71
71
 
72
72
  # Version of the scanner
73
- SCANNER_VERSION = "0.2.2"
73
+ SCANNER_VERSION = "0.2.4"
74
74
 
75
75
  # Domain groupings for controls (matches MTF v0.1 spec)
76
76
  DOMAINS = {
@@ -0,0 +1,144 @@
1
+ """End-to-end tests against real bundles from the mpak registry.
2
+
3
+ These tests scan real published bundles to catch false positives and
4
+ regressions that synthetic fixtures miss.
5
+
6
+ Setup:
7
+ mpak bundle pull @nimblebraininc/finnhub -o tests/data/finnhub.mcpb
8
+ mpak bundle pull @nimblebraininc/folk -o tests/data/folk.mcpb
9
+ mpak bundle pull @nimblebraininc/nationalparks -o tests/data/nationalparks.mcpb
10
+
11
+ Run:
12
+ uv run pytest tests/test_e2e_bundles.py -v
13
+ uv run pytest -m e2e -v
14
+ """
15
+
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ from mpak_scanner import scan_bundle
21
+ from mpak_scanner.models import ControlStatus, Severity
22
+
23
+ DATA_DIR = Path(__file__).parent / "data"
24
+
25
+ # Bundle paths
26
+ FINNHUB = DATA_DIR / "finnhub.mcpb"
27
+ FOLK = DATA_DIR / "folk.mcpb"
28
+ NATIONALPARKS = DATA_DIR / "nationalparks.mcpb"
29
+
30
+ ALL_BUNDLES = [
31
+ pytest.param(FINNHUB, id="finnhub"),
32
+ pytest.param(FOLK, id="folk"),
33
+ pytest.param(NATIONALPARKS, id="nationalparks"),
34
+ ]
35
+
36
+ PYTHON_BUNDLES = [
37
+ pytest.param(FINNHUB, id="finnhub"),
38
+ pytest.param(FOLK, id="folk"),
39
+ ]
40
+
41
+ NODE_BUNDLES = [
42
+ pytest.param(NATIONALPARKS, id="nationalparks"),
43
+ ]
44
+
45
+
46
+ def skip_if_missing(bundle_path: Path) -> None:
47
+ if not bundle_path.exists():
48
+ pytest.skip(f"Bundle not found: {bundle_path.name} (run: mpak bundle pull ... -o {bundle_path})")
49
+
50
+
51
+ @pytest.mark.e2e
52
+ class TestBundleCompleteness:
53
+ """AI-05: Real bundles should not have false-positive unexpected executables."""
54
+
55
+ @pytest.mark.parametrize("bundle", ALL_BUNDLES)
56
+ def test_ai05_passes(self, bundle: Path) -> None:
57
+ """AI-05 should PASS on all published bundles (no false positives)."""
58
+ skip_if_missing(bundle)
59
+ report = scan_bundle(bundle)
60
+
61
+ ai05 = report.all_controls.get("AI-05")
62
+ assert ai05 is not None
63
+ assert ai05.status == ControlStatus.PASS, f"AI-05 false positives on {bundle.name}: " + ", ".join(
64
+ f.title for f in ai05.findings if f.severity in {Severity.HIGH, Severity.CRITICAL}
65
+ )
66
+
67
+ @pytest.mark.parametrize("bundle", ALL_BUNDLES)
68
+ def test_no_high_or_critical_in_ai05(self, bundle: Path) -> None:
69
+ """AI-05 should have zero HIGH/CRITICAL findings on published bundles."""
70
+ skip_if_missing(bundle)
71
+ report = scan_bundle(bundle)
72
+
73
+ ai05 = report.all_controls.get("AI-05")
74
+ assert ai05 is not None
75
+ blocking = [f for f in ai05.findings if f.severity in {Severity.HIGH, Severity.CRITICAL}]
76
+ assert blocking == [], f"Blocking findings on {bundle.name}: {[f.title for f in blocking]}"
77
+
78
+
79
+ @pytest.mark.e2e
80
+ class TestManifestValidation:
81
+ """AI-01: Real bundles should have valid manifests."""
82
+
83
+ @pytest.mark.parametrize("bundle", ALL_BUNDLES)
84
+ def test_ai01_passes(self, bundle: Path) -> None:
85
+ skip_if_missing(bundle)
86
+ report = scan_bundle(bundle)
87
+
88
+ ai01 = report.all_controls.get("AI-01")
89
+ assert ai01 is not None
90
+ assert ai01.status == ControlStatus.PASS, f"AI-01 failed on {bundle.name}: {ai01.findings}"
91
+
92
+
93
+ @pytest.mark.e2e
94
+ class TestSafeExecution:
95
+ """CQ-05: Real bundles should pass safe execution checks."""
96
+
97
+ @pytest.mark.parametrize("bundle", ALL_BUNDLES)
98
+ def test_cq05_passes(self, bundle: Path) -> None:
99
+ skip_if_missing(bundle)
100
+ report = scan_bundle(bundle)
101
+
102
+ cq05 = report.all_controls.get("CQ-05")
103
+ assert cq05 is not None
104
+ assert cq05.status == ControlStatus.PASS, f"CQ-05 failed on {bundle.name}: {cq05.findings}"
105
+
106
+
107
+ @pytest.mark.e2e
108
+ class TestFullScan:
109
+ """Full scan results for each bundle."""
110
+
111
+ @pytest.mark.parametrize("bundle", PYTHON_BUNDLES)
112
+ def test_python_bundles_no_critical_findings(self, bundle: Path) -> None:
113
+ """Python bundles should have no CRITICAL findings across all controls."""
114
+ skip_if_missing(bundle)
115
+ report = scan_bundle(bundle)
116
+
117
+ critical = []
118
+ for control_id, result in report.all_controls.items():
119
+ for f in result.findings:
120
+ if f.severity == Severity.CRITICAL:
121
+ critical.append(f"{control_id}: {f.title}")
122
+ assert critical == [], f"Critical findings on {bundle.name}: {critical}"
123
+
124
+ @pytest.mark.parametrize("bundle", NODE_BUNDLES)
125
+ def test_node_bundles_no_critical_findings(self, bundle: Path) -> None:
126
+ """Node.js bundles should have no CRITICAL findings across all controls."""
127
+ skip_if_missing(bundle)
128
+ report = scan_bundle(bundle)
129
+
130
+ critical = []
131
+ for control_id, result in report.all_controls.items():
132
+ for f in result.findings:
133
+ if f.severity == Severity.CRITICAL:
134
+ critical.append(f"{control_id}: {f.title}")
135
+ assert critical == [], f"Critical findings on {bundle.name}: {critical}"
136
+
137
+ @pytest.mark.parametrize("bundle", ALL_BUNDLES)
138
+ def test_scan_completes_without_errors(self, bundle: Path) -> None:
139
+ """Scanner should not produce ERROR status on any control."""
140
+ skip_if_missing(bundle)
141
+ report = scan_bundle(bundle)
142
+
143
+ errors = [f"{cid}: {r.findings}" for cid, r in report.all_controls.items() if r.status == ControlStatus.ERROR]
144
+ assert errors == [], f"Controls errored on {bundle.name}: {errors}"
@@ -862,6 +862,93 @@ class TestAI05BundleCompleteness:
862
862
  assert result.status == ControlStatus.FAIL
863
863
  assert any("backdoor.py" in f.file for f in result.findings if f.file)
864
864
 
865
+ def test_node_multi_file_build_passes(self, bundle_dir: Path) -> None:
866
+ """Node.js entry point in build/ should treat sibling modules as referenced."""
867
+ manifest = {
868
+ "name": "@test/node-multi",
869
+ "version": "1.0.0",
870
+ "server": {
871
+ "type": "node",
872
+ "entry_point": "build/index.js",
873
+ "mcp_config": {
874
+ "command": "node",
875
+ "args": ["${__dirname}/build/index.js", "--stdio"],
876
+ },
877
+ },
878
+ }
879
+ (bundle_dir / "manifest.json").write_text(json.dumps(manifest))
880
+ (bundle_dir / "package.json").write_text('{"name": "test"}')
881
+ build = bundle_dir / "build"
882
+ build.mkdir()
883
+ (build / "index.js").write_text("import './config.js';")
884
+ (build / "config.js").write_text("export const cfg = {};")
885
+ (build / "schemas.js").write_text("export const schemas = {};")
886
+ handlers = build / "handlers"
887
+ handlers.mkdir()
888
+ (handlers / "findParks.js").write_text("export function findParks() {}")
889
+ (handlers / "getAlerts.js").write_text("export function getAlerts() {}")
890
+
891
+ from mpak_scanner.controls.artifact_integrity import AI05BundleCompleteness
892
+
893
+ control = AI05BundleCompleteness()
894
+ result = control.run(bundle_dir, manifest)
895
+ assert result.status == ControlStatus.PASS
896
+
897
+ def test_node_build_with_stray_script_fails(self, bundle_dir: Path) -> None:
898
+ """Node.js build/ files are allowed but stray scripts at root should fail."""
899
+ manifest = {
900
+ "name": "@test/node-stray",
901
+ "version": "1.0.0",
902
+ "server": {
903
+ "type": "node",
904
+ "entry_point": "build/index.js",
905
+ "mcp_config": {
906
+ "command": "node",
907
+ "args": ["${__dirname}/build/index.js"],
908
+ },
909
+ },
910
+ }
911
+ (bundle_dir / "manifest.json").write_text(json.dumps(manifest))
912
+ build = bundle_dir / "build"
913
+ build.mkdir()
914
+ (build / "index.js").write_text("console.log('ok')")
915
+ # Stray script outside build/
916
+ (bundle_dir / "deploy.sh").write_text("#!/bin/bash\nrm -rf /")
917
+
918
+ from mpak_scanner.controls.artifact_integrity import AI05BundleCompleteness
919
+
920
+ control = AI05BundleCompleteness()
921
+ result = control.run(bundle_dir, manifest)
922
+ assert result.status == ControlStatus.FAIL
923
+ assert any("deploy.sh" in f.file for f in result.findings if f.file)
924
+ # build/index.js should NOT be flagged
925
+ assert not any("build/index.js" in f.file for f in result.findings if f.file)
926
+
927
+ def test_node_root_entry_point_no_over_allow(self, bundle_dir: Path) -> None:
928
+ """Node.js entry point at root should not whitelist all files."""
929
+ manifest = {
930
+ "name": "@test/node-root",
931
+ "version": "1.0.0",
932
+ "server": {
933
+ "type": "node",
934
+ "entry_point": "index.js",
935
+ "mcp_config": {
936
+ "command": "node",
937
+ "args": ["${__dirname}/index.js"],
938
+ },
939
+ },
940
+ }
941
+ (bundle_dir / "manifest.json").write_text(json.dumps(manifest))
942
+ (bundle_dir / "index.js").write_text("console.log('ok')")
943
+ (bundle_dir / "backdoor.js").write_text("require('child_process').exec('evil')")
944
+
945
+ from mpak_scanner.controls.artifact_integrity import AI05BundleCompleteness
946
+
947
+ control = AI05BundleCompleteness()
948
+ result = control.run(bundle_dir, manifest)
949
+ assert result.status == ControlStatus.FAIL
950
+ assert any("backdoor.js" in f.file for f in result.findings if f.file)
951
+
865
952
 
866
953
  # =============================================================================
867
954
  # Fixture-based Integration Tests
@@ -464,7 +464,7 @@ wheels = [
464
464
 
465
465
  [[package]]
466
466
  name = "mpak-scanner"
467
- version = "0.2.2"
467
+ version = "0.2.4"
468
468
  source = { editable = "." }
469
469
  dependencies = [
470
470
  { name = "click" },
@@ -1,4 +0,0 @@
1
- FROM python:3.13-slim
2
- RUN pip install --no-cache-dir "mpak-scanner[job]==0.2.2"
3
- ENTRYPOINT ["mpak-scanner"]
4
- CMD ["job"]
File without changes
File without changes