tunnel-mcp 0.1.5 → 0.1.6

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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  - Nothing yet.
13
13
 
14
+ ## [0.1.6] - 2026-07-01
15
+
16
+ ### Security
17
+
18
+ - **The auto-downloaded cloudflared binary is now pinned and integrity-verified.**
19
+ Instead of pulling `releases/latest` unverified, tunnel-mcp downloads a pinned
20
+ cloudflared version and checks its SHA-256 against a hash committed in the
21
+ source (and covered by the npm provenance attestation) before the binary is
22
+ extracted, installed, or made executable — so a tampered or wrong-version
23
+ binary is refused, not run. Bump with `scripts/refresh-cloudflared-hashes.mjs`.
24
+ - **Supply-chain hardening of the pipeline:** every GitHub Action is pinned to a
25
+ full commit SHA (not a mutable tag), workflow token permissions default to
26
+ read-only, a `dependency-review` gate blocks PRs that introduce known-vulnerable
27
+ dependencies, and OpenSSF Scorecard + CodeQL scanning run on the repository. See
28
+ the new "Supply chain" section in `SECURITY.md`.
29
+
14
30
  ## [0.1.5] - 2026-07-01
15
31
 
16
32
  ### Changed
@@ -132,7 +148,8 @@ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
132
148
  declaring a fix "confirmed".
133
149
  - Test suite of 109 tests built with vitest, developed test-first (TDD).
134
150
 
135
- [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.5...HEAD
151
+ [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.6...HEAD
152
+ [0.1.6]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.5...v0.1.6
136
153
  [0.1.5]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.4...v0.1.5
137
154
  [0.1.4]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.3...v0.1.4
138
155
  [0.1.3]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.2...v0.1.3
package/SECURITY.md CHANGED
@@ -83,6 +83,36 @@ share a join link with anyone.
83
83
  `tunnel_close`, an idle timeout (30 minutes with no messages), or the
84
84
  host process exiting. Nothing persists past teardown.
85
85
 
86
+ ## Supply chain
87
+
88
+ tunnel-mcp is a security-sensitive tool, so its build and distribution chain is
89
+ hardened against tampering:
90
+
91
+ - **npm provenance.** Releases are published from GitHub Actions via npm Trusted
92
+ Publishing (OIDC) — no long-lived npm token exists — and every published
93
+ version carries a signed provenance attestation. You can verify a release was
94
+ built from this repository with `npm audit signatures`.
95
+ - **The auto-downloaded cloudflared binary is pinned and verified.** tunnel-mcp
96
+ fetches a specific pinned cloudflared version and checks the artifact's SHA-256
97
+ against a hash committed in the source (and covered by the provenance
98
+ attestation) **before** it is extracted, moved into place, or made executable.
99
+ A mismatched or tampered binary is refused, not run.
100
+ - **Pinned, reviewed dependencies.** Production dependencies are minimal and
101
+ installed from a committed lockfile with integrity hashes (`npm ci`).
102
+ Dependabot proposes updates, and a `dependency-review` gate blocks any pull
103
+ request that would introduce a dependency with a known high-severity
104
+ vulnerability.
105
+ - **Pinned GitHub Actions + least privilege.** Every third-party GitHub Action is
106
+ pinned to a full commit SHA (not a mutable tag), and workflow `GITHUB_TOKEN`
107
+ permissions default to read-only, scoped up only where a job requires it.
108
+ - **Continuous scanning.** OpenSSF Scorecard tracks the repository's
109
+ supply-chain posture, and CodeQL runs static analysis on every push and pull
110
+ request.
111
+
112
+ To bump the pinned cloudflared version, a maintainer runs
113
+ `node scripts/refresh-cloudflared-hashes.mjs <version>` and commits the updated
114
+ version and checksums, keeping the pin auditable.
115
+
86
116
  ## Known Limitations / Threat Model
87
117
 
88
118
  This is an MVP and it is important to be honest about what it does **not**
@@ -1,18 +1,27 @@
1
+ export declare const CLOUDFLARED_VERSION = "2026.6.1";
2
+ export declare const CLOUDFLARED_SHA256: Record<string, string>;
1
3
  export declare function cloudflaredBinName(platform: NodeJS.Platform): string;
4
+ export declare function cloudflaredAsset(platform: NodeJS.Platform, arch: string): string;
2
5
  export declare function cloudflaredDownloadUrl(platform: NodeJS.Platform, arch: string): string;
6
+ export declare function expectedSha256(platform: NodeJS.Platform, arch: string): string;
3
7
  export interface DownloadDeps {
4
8
  fetchImpl?: typeof fetch;
9
+ expectedSha256?: string;
5
10
  }
6
11
  /**
7
12
  * Download cloudflared from `url` and install it at `destBinPath`.
8
13
  *
14
+ * Integrity: when `deps.expectedSha256` is provided, the downloaded artifact is
15
+ * hashed and rejected on any mismatch BEFORE it is extracted, moved into place,
16
+ * or made executable — so a corrupt, tampered, or wrong-version binary never
17
+ * reaches the cache or runs.
18
+ *
9
19
  * Atomic: the binary is downloaded/extracted into a unique location under
10
20
  * os.tmpdir() and only moved into `destBinPath` once it is fully present and
11
- * valid, so a failed/partial download never poisons the destination (callers
12
- * that check `fs.existsSync(destBinPath)` to skip re-downloading are safe).
21
+ * verified, so a failed/partial download never poisons the destination.
13
22
  *
14
- * Any failure anywhere in this path (network, tar extraction, fs ops) is
15
- * caught, temp artifacts (and any partially-installed dest) are cleaned up,
23
+ * Any failure anywhere in this path (network, checksum, tar extraction, fs ops)
24
+ * is caught, temp artifacts (and any partially-installed dest) are cleaned up,
16
25
  * and a single readable error with a manual-install pointer is thrown.
17
26
  */
18
27
  export declare function downloadCloudflared(url: string, destBinPath: string, deps?: DownloadDeps): Promise<void>;
@@ -6,8 +6,23 @@ import { execSync, execFileSync } from 'node:child_process';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { Readable } from 'node:stream';
8
8
  import { BIN_DIR } from '../config.js';
9
- const RELEASE_BASE = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
9
+ // Pin a specific cloudflared release and verify the downloaded artifact against a
10
+ // SHA-256 committed here (and provenance-attested when the package is published),
11
+ // so the tool never executes an unverified binary and never silently picks up a
12
+ // changed "latest". Bump both together with:
13
+ // node scripts/refresh-cloudflared-hashes.mjs <version>
14
+ export const CLOUDFLARED_VERSION = '2026.6.1';
15
+ const RELEASE_BASE = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`;
10
16
  const MANUAL_INSTALL_POINTER = 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
17
+ // SHA-256 of each pinned release asset (Cloudflare does not publish a checksum
18
+ // manifest, so these are computed from the official assets by the refresh script).
19
+ export const CLOUDFLARED_SHA256 = {
20
+ 'cloudflared-darwin-amd64.tgz': 'd7a66b525fe76820da6e5406611b61e48b40de682368ac00454d9158f085be4b',
21
+ 'cloudflared-darwin-arm64.tgz': 'f6d4c439c6c782b83264951d327989ce5e23373acc5942b872411601fedb020d',
22
+ 'cloudflared-linux-amd64': '5861a10a438fe8ddcfebb3b830f83966cbf193edafce0fe2eeb198fbae1f7a22',
23
+ 'cloudflared-linux-arm64': '59816ce9b16db71f5bc2a86d59b3632a96c8c3ee934bde2bc8641ee83a6070eb',
24
+ 'cloudflared-windows-amd64.exe': '5253e66f1f493c4e13539749f1aa86fd0c61e3072900fec29a44ba046a6d97e2',
25
+ };
11
26
  function arch2cf(arch) {
12
27
  if (arch === 'x64')
13
28
  return 'amd64';
@@ -18,16 +33,34 @@ function arch2cf(arch) {
18
33
  export function cloudflaredBinName(platform) {
19
34
  return platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
20
35
  }
21
- export function cloudflaredDownloadUrl(platform, arch) {
36
+ // The release asset filename for this platform/arch — the single key shared by
37
+ // the download URL and the pinned-checksum lookup, so they can never disagree.
38
+ export function cloudflaredAsset(platform, arch) {
22
39
  const a = arch2cf(arch);
23
40
  if (platform === 'darwin')
24
- return `${RELEASE_BASE}/cloudflared-darwin-${a}.tgz`;
41
+ return `cloudflared-darwin-${a}.tgz`;
25
42
  if (platform === 'linux')
26
- return `${RELEASE_BASE}/cloudflared-linux-${a}`;
43
+ return `cloudflared-linux-${a}`;
27
44
  if (platform === 'win32')
28
- return `${RELEASE_BASE}/cloudflared-windows-${a}.exe`;
45
+ return `cloudflared-windows-${a}.exe`;
29
46
  throw new Error(`unsupported platform: ${platform}`);
30
47
  }
48
+ export function cloudflaredDownloadUrl(platform, arch) {
49
+ return `${RELEASE_BASE}/${cloudflaredAsset(platform, arch)}`;
50
+ }
51
+ export function expectedSha256(platform, arch) {
52
+ const asset = cloudflaredAsset(platform, arch);
53
+ const sha = CLOUDFLARED_SHA256[asset];
54
+ if (!sha) {
55
+ throw new Error(`no pinned checksum for ${asset} (cloudflared ${CLOUDFLARED_VERSION})`);
56
+ }
57
+ return sha;
58
+ }
59
+ async function sha256File(p) {
60
+ const hash = crypto.createHash('sha256');
61
+ await pipeline(fs.createReadStream(p), hash);
62
+ return hash.digest('hex');
63
+ }
31
64
  function onPath() {
32
65
  try {
33
66
  const cmd = process.platform === 'win32' ? 'where cloudflared' : 'command -v cloudflared';
@@ -68,13 +101,17 @@ function rmQuiet(p) {
68
101
  /**
69
102
  * Download cloudflared from `url` and install it at `destBinPath`.
70
103
  *
104
+ * Integrity: when `deps.expectedSha256` is provided, the downloaded artifact is
105
+ * hashed and rejected on any mismatch BEFORE it is extracted, moved into place,
106
+ * or made executable — so a corrupt, tampered, or wrong-version binary never
107
+ * reaches the cache or runs.
108
+ *
71
109
  * Atomic: the binary is downloaded/extracted into a unique location under
72
110
  * os.tmpdir() and only moved into `destBinPath` once it is fully present and
73
- * valid, so a failed/partial download never poisons the destination (callers
74
- * that check `fs.existsSync(destBinPath)` to skip re-downloading are safe).
111
+ * verified, so a failed/partial download never poisons the destination.
75
112
  *
76
- * Any failure anywhere in this path (network, tar extraction, fs ops) is
77
- * caught, temp artifacts (and any partially-installed dest) are cleaned up,
113
+ * Any failure anywhere in this path (network, checksum, tar extraction, fs ops)
114
+ * is caught, temp artifacts (and any partially-installed dest) are cleaned up,
78
115
  * and a single readable error with a manual-install pointer is thrown.
79
116
  */
80
117
  export async function downloadCloudflared(url, destBinPath, deps = {}) {
@@ -90,6 +127,13 @@ export async function downloadCloudflared(url, destBinPath, deps = {}) {
90
127
  throw new Error(`cloudflared download failed${status}`);
91
128
  }
92
129
  await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpFile));
130
+ // Verify integrity of the downloaded asset before trusting it in any way.
131
+ if (deps.expectedSha256) {
132
+ const actual = await sha256File(tmpFile);
133
+ if (actual.toLowerCase() !== deps.expectedSha256.toLowerCase()) {
134
+ throw new Error(`checksum mismatch — expected ${deps.expectedSha256}, got ${actual}. Refusing to install a binary that does not match the pinned cloudflared release`);
135
+ }
136
+ }
93
137
  if (url.endsWith('.tgz')) {
94
138
  fs.mkdirSync(tmpExtractDir, { recursive: true });
95
139
  execFileSync('tar', ['-xzf', tmpFile, '-C', tmpExtractDir]); // extracts a `cloudflared` binary
@@ -125,6 +169,8 @@ export async function ensureCloudflared() {
125
169
  return dest;
126
170
  fs.mkdirSync(BIN_DIR, { recursive: true });
127
171
  const url = cloudflaredDownloadUrl(process.platform, process.arch);
128
- await downloadCloudflared(url, dest);
172
+ await downloadCloudflared(url, dest, {
173
+ expectedSha256: expectedSha256(process.platform, process.arch),
174
+ });
129
175
  return dest;
130
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnel-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Let two developers' Claude agents talk directly through an ephemeral, end-to-end-encrypted tunnel.",
5
5
  "type": "module",
6
6
  "bin": {