* fix(#4120): replace shellcheck npm dep with dependency-free downloader The `shellcheck` devDependency (added in #4109) pulled in decompress@4.2.1 for archive extraction, which carries an unpatched CRITICAL zip-slip vulnerability (GHSA-mp2f-45pm-3cg9, CVSS 9.1) plus two moderate findings. decompress's latest published version IS the vulnerable one -- no patched release exists upstream, so npm audit fix cannot resolve this by upgrading. Removes the shellcheck package entirely and replaces its role with scripts/lib/shellcheck-fetch.cjs: a small downloader using only Node's built-in https/zlib plus a hand-written tar-entry reader, fetching a pinned koalaman/shellcheck release directly from GitHub releases. The reader never uses an archive-supplied name as a filesystem path (the exact defect class decompress had) -- it only returns the matched entry's bytes; the caller writes those bytes to a path it constructs itself. Bounds the download with a 30s-per-hop timeout, consistent with the ShellCheck subprocess's own timeout. Covers linux/darwin on x86_64/aarch64, matching this repo's actual CI (lint-tests runs only on ubuntu-latest) and local dev needs; Windows fails with a clear, honest error rather than silently misbehaving. Adds tests/lint-workflow-shellcheck-fetch.test.cjs covering the tar-parser (unit cases plus a fast-check property test per CLAUDE.md's parser-testing requirement), a security behavioral pin confirming traversal-style entry names are treated as opaque strings never filesystem paths, and boundary coverage for the redirect-following logic's MAX_REDIRECTS limit (limit-1/limit/limit+1, via an injectable transport, no real network I/O). npm audit: 0 vulnerabilities (was 1 critical + 5 moderate). The lint script reproduces the identical result against the current tree: "212 pre-existing finding(s) from baseline, 0 new" -- no behavior regression, no baseline changes needed. * fix(#4120): register shellcheck-fetch.cjs with the installer scripts/lib/shellcheck-fetch.cjs shipped without being added to GSD_SCRIPTS_LIB_FILES in bin/install.js, which would have left it orphaned on uninstall and broken the golden install-tree fixtures for every runtime. Adds the entry and regenerates the 19 affected fixtures via npm run gen:install-tree. * docs(#4120): add changeset for the decompress CVE fix --------- Co-authored-by: sim <sim@local>
This commit is contained in:
247
scripts/lib/shellcheck-fetch.cjs
Normal file
247
scripts/lib/shellcheck-fetch.cjs
Normal file
@@ -0,0 +1,247 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* shellcheck-fetch.cjs
|
||||
*
|
||||
* Dependency-free replacement for the `shellcheck` npm package (removed in
|
||||
* #4120 — its extraction step pulled in `decompress@4.2.1`, which carries an
|
||||
* unpatched CRITICAL zip-slip vulnerability, GHSA-mp2f-45pm-3cg9, CVSS 9.1,
|
||||
* plus two moderate findings, with no patched version available upstream).
|
||||
*
|
||||
* This module fetches a PINNED koalaman/shellcheck release directly from
|
||||
* GitHub releases (never "latest" — see SHELLCHECK_VERSION below), extracts
|
||||
* the single `shellcheck` binary from the release's `.tar.gz` asset using
|
||||
* only Node's built-in `https`/`zlib` modules plus a small hand-written tar
|
||||
* reader (no third-party archive library), and caches the extracted binary
|
||||
* for reuse across runs.
|
||||
*
|
||||
* Zip-slip defense: unlike `decompress`, which wrote extracted files using
|
||||
* PATHS TAKEN FROM THE ARCHIVE (the exact defect class in GHSA-mp2f-45pm-
|
||||
* 3cg9 — a malicious archive entry named e.g. `../../etc/passwd` gets
|
||||
* written there verbatim), this reader NEVER uses an archive-supplied name
|
||||
* as a filesystem path. `extractFileFromTar` only ever returns the matched
|
||||
* entry's raw byte content; the caller (`resolveShellcheckBin`) writes those
|
||||
* bytes to a path it constructs itself (`<cacheDir>/shellcheck`), and the
|
||||
* archive's own name field is used only for a string-equality/suffix CHECK
|
||||
* (`name === targetName || name.endsWith('/' + targetName)`), never
|
||||
* interpolated into a path passed to `fs.writeFileSync`/`fs.mkdirSync`/etc.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const https = require('node:https');
|
||||
const { ExitError } = require('./cli-exit.cjs');
|
||||
|
||||
// Pinned explicitly — verified against `koalaman/shellcheck`'s GitHub
|
||||
// releases API as the current latest tag at the time this was written
|
||||
// (2026-08-31). Never resolved dynamically ("latest") — a moving target
|
||||
// would make this lint's exact ShellCheck version, and therefore its exact
|
||||
// finding set against scripts/lint-workflow-shellcheck-baseline.json,
|
||||
// non-reproducible across runs/machines/CI.
|
||||
const SHELLCHECK_VERSION = 'v0.11.0';
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const CACHE_DIR = path.join(ROOT, 'node_modules', '.cache', 'shellcheck', SHELLCHECK_VERSION);
|
||||
const CACHED_BIN_PATH = path.join(CACHE_DIR, 'shellcheck');
|
||||
|
||||
// process.arch -> the arch token ShellCheck's release asset names use.
|
||||
// Only the two architectures that actually matter for this repo (per
|
||||
// .github/workflows/test.yml: the lint-tests job that runs this script only
|
||||
// runs on ubuntu-latest, which is x86_64; and Apple Silicon dev machines are
|
||||
// aarch64) are supported — anything else fails with a clear error rather
|
||||
// than guessing.
|
||||
const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
// Bounds each individual HTTP hop (the initial request AND every redirect
|
||||
// hop get their own fresh 30s budget, rather than one shared budget across
|
||||
// the whole redirect chain) — a stalled connection on any single hop is
|
||||
// caught in a bounded time, mirroring lint-workflow-shellcheck.cjs's own
|
||||
// SHELLCHECK_TIMEOUT_MS bound on the ShellCheck subprocess. A bare `timeout`
|
||||
// option on the request does NOT abort it by itself — Node only emits a
|
||||
// 'timeout' event, which must be handled by destroying the request (see the
|
||||
// `req.on('timeout', ...)` below).
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Issue one real HTTPS request. Exists as its own function purely so tests
|
||||
* can inject a fake in its place (see `httpsGetFollowingRedirects`'s
|
||||
* `requestFn` parameter) — production callers never pass an override, so
|
||||
* the real download path always uses this exact implementation.
|
||||
*/
|
||||
function defaultRequestFn(url, options, callback) {
|
||||
return https.get(url, options, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET `url` following HTTP redirects manually — `https.get` does NOT follow
|
||||
* redirects automatically, and GitHub release asset URLs redirect through
|
||||
* `objects.githubusercontent.com`. Resolves with the full response body as a
|
||||
* Buffer once a 200 response is received.
|
||||
*
|
||||
* `requestFn` defaults to a real `https.get`-based transport
|
||||
* (`defaultRequestFn`) and is only ever overridden in tests, so calling this
|
||||
* with zero/one arg from `resolveShellcheckBin` is unchanged behavior.
|
||||
*/
|
||||
function httpsGetFollowingRedirects(url, redirectsLeft = MAX_REDIRECTS, requestFn = defaultRequestFn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = requestFn(
|
||||
url,
|
||||
{ headers: { 'User-Agent': 'gsd-core-shellcheck-fetch' }, timeout: DOWNLOAD_TIMEOUT_MS },
|
||||
(res) => {
|
||||
const status = res.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume(); // drain so the socket can be reused/closed
|
||||
if (redirectsLeft <= 0) {
|
||||
reject(new Error(`too many redirects fetching ${url}`));
|
||||
return;
|
||||
}
|
||||
const next = new URL(res.headers.location, url).toString();
|
||||
httpsGetFollowingRedirects(next, redirectsLeft - 1, requestFn).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
if (status !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`unexpected HTTP ${status} fetching ${url}`));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
res.on('error', reject);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
// `timeout` in the options above only ARMS a timer — Node emits a
|
||||
// 'timeout' event on the request but does not abort it. Without this
|
||||
// handler the request (and this Promise) would hang indefinitely past
|
||||
// the configured bound on a stalled connection.
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error(`timed out after ${DOWNLOAD_TIMEOUT_MS}ms fetching ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the entry named (or path-ending-in) `targetName` inside a raw
|
||||
* (already gunzipped) POSIX tar byte stream and return its content as a
|
||||
* Buffer, or `null` if not found.
|
||||
*
|
||||
* Tar format: a sequence of 512-byte headers — name at offset 0/length 100,
|
||||
* size at offset 124/length 12 (octal ASCII), typeflag at offset 156 — each
|
||||
* followed by that many content bytes, padded up to the next 512-byte
|
||||
* boundary, terminated by an all-zero 512-byte block. This deliberately
|
||||
* implements only enough to locate ONE known entry name (no general
|
||||
* multi-file extraction, no symlink handling, no GNU long-name `@LongLink`
|
||||
* entries — ShellCheck's own release tarballs never need them) — see this
|
||||
* module's header comment for why the entry's NAME is never used as a
|
||||
* filesystem path.
|
||||
*/
|
||||
function extractFileFromTar(buffer, targetName) {
|
||||
let offset = 0;
|
||||
while (offset + 512 <= buffer.length) {
|
||||
const header = buffer.subarray(offset, offset + 512);
|
||||
if (header.every((b) => b === 0)) break; // end-of-archive marker
|
||||
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
|
||||
const sizeRaw = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim();
|
||||
const size = sizeRaw === '' ? 0 : parseInt(sizeRaw, 8);
|
||||
const typeflag = String.fromCharCode(header[156]);
|
||||
const dataStart = offset + 512;
|
||||
const isRegularFile = typeflag === '0' || typeflag === '\0';
|
||||
if (isRegularFile && (name === targetName || name.endsWith(`/${targetName}`))) {
|
||||
return buffer.subarray(dataStart, dataStart + size);
|
||||
}
|
||||
const contentBlocks = Math.ceil(size / 512);
|
||||
offset = dataStart + contentBlocks * 512;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the local path to a working, executable `shellcheck` binary,
|
||||
* downloading and caching the pinned release on first use. Subsequent calls
|
||||
* (same version) reuse the cached binary with no network activity — mirrors
|
||||
* the `fs.accessSync(bin, F_OK | X_OK)` cache-check pattern already used by
|
||||
* this script's own `runShellcheck`.
|
||||
*/
|
||||
async function resolveShellcheckBin() {
|
||||
try {
|
||||
fs.accessSync(CACHED_BIN_PATH, fs.constants.F_OK | fs.constants.X_OK);
|
||||
return CACHED_BIN_PATH;
|
||||
} catch {
|
||||
// not cached yet — fall through to download
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
throw new ExitError(
|
||||
1,
|
||||
'lint-workflow-shellcheck: automatic ShellCheck download is not supported on Windows yet ' +
|
||||
'(this lint only ever runs in the ubuntu-latest lint-tests CI job — see .github/workflows/test.yml — ' +
|
||||
'so this is an honest platform gap, not expected to be hit in CI).',
|
||||
);
|
||||
}
|
||||
const platform = process.platform === 'darwin' || process.platform === 'linux' ? process.platform : null;
|
||||
if (!platform) {
|
||||
throw new ExitError(
|
||||
1,
|
||||
`lint-workflow-shellcheck: unsupported platform '${process.platform}' for ShellCheck auto-download ` +
|
||||
`(supported: linux, darwin).`,
|
||||
);
|
||||
}
|
||||
const arch = ARCH_MAP[process.arch];
|
||||
if (!arch) {
|
||||
throw new ExitError(
|
||||
1,
|
||||
`lint-workflow-shellcheck: unsupported architecture '${process.arch}' for ShellCheck auto-download ` +
|
||||
`(supported: x86_64 [node arch 'x64'], aarch64 [node arch 'arm64']).`,
|
||||
);
|
||||
}
|
||||
|
||||
const assetName = `shellcheck-${SHELLCHECK_VERSION}.${platform}.${arch}.tar.gz`;
|
||||
const url = `https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/${assetName}`;
|
||||
|
||||
let gz;
|
||||
try {
|
||||
gz = await httpsGetFollowingRedirects(url);
|
||||
} catch (e) {
|
||||
throw new ExitError(1, `lint-workflow-shellcheck: failed to download ShellCheck (${url}): ${e.message}`);
|
||||
}
|
||||
|
||||
let tarBuf;
|
||||
try {
|
||||
tarBuf = zlib.gunzipSync(gz);
|
||||
} catch (e) {
|
||||
throw new ExitError(1, `lint-workflow-shellcheck: failed to gunzip downloaded ShellCheck archive: ${e.message}`);
|
||||
}
|
||||
|
||||
const entry = extractFileFromTar(tarBuf, 'shellcheck');
|
||||
if (!entry) {
|
||||
throw new ExitError(
|
||||
1,
|
||||
`lint-workflow-shellcheck: could not find a 'shellcheck' entry inside downloaded archive ${assetName}`,
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
// Write to a per-process temp path and rename into place — avoids any
|
||||
// other concurrent invocation observing (and trying to execute) a
|
||||
// partially-written binary at the real cache path.
|
||||
const tmpPath = path.join(CACHE_DIR, `.shellcheck.tmp-${process.pid}`);
|
||||
fs.writeFileSync(tmpPath, entry);
|
||||
fs.chmodSync(tmpPath, 0o755);
|
||||
fs.renameSync(tmpPath, CACHED_BIN_PATH);
|
||||
|
||||
return CACHED_BIN_PATH;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SHELLCHECK_VERSION,
|
||||
CACHED_BIN_PATH,
|
||||
MAX_REDIRECTS,
|
||||
DOWNLOAD_TIMEOUT_MS,
|
||||
extractFileFromTar,
|
||||
httpsGetFollowingRedirects,
|
||||
resolveShellcheckBin,
|
||||
};
|
||||
Reference in New Issue
Block a user