Skip to content

Commit f7080a0

Browse files
ci(release): trust the local registry only after it proves its identity
The publish checks accepted any successful answer on the registry port, so another local account holding localhost:4873 could serve the packages the checks install and run. Child exit was only noticed after a failed answer. Bind Verdaccio to 127.0.0.1 after checking the port is free, require it to answer with a per-run secret seeded into its private storage, and stop any registry command once it exits. waitFor no longer accepts an answer from a port whose process has exited. After publishing, the registry must serve the exact tarball, and every install is compared file by file with it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e4ff5c8 commit f7080a0

3 files changed

Lines changed: 170 additions & 13 deletions

File tree

‎scripts/e2e-install.mjs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,14 +472,15 @@ async function newAngularApp(cell, { ssr }) {
472472
return app;
473473
}
474474

475-
/** Checks the package manager installed the version this run published. */
475+
/** Checks the package manager installed the version and the tarball this run published. */
476476
function checkInstalled(cell, app) {
477477
const file = path.join(app, 'node_modules', manifest.name, 'package.json');
478478
if (!existsSync(file)) throw new Error(`${manifest.name} is not in ${app}/node_modules`);
479479
const installed = readJson(file).version;
480480
if (installed !== cell.version) {
481481
throw new Error(`installed ${manifest.name}@${installed}, expected ${cell.version}`);
482482
}
483+
registry.verifyInstalled(app);
483484
}
484485

485486
const ngBin = (app) => path.join(app, 'node_modules/.bin/ng');
@@ -839,6 +840,9 @@ async function packageAudit(tarball, version) {
839840
pmEnv(),
840841
),
841842
);
843+
await step(result, 'the installed package is the published tarball', () =>
844+
registry.verifyInstalled(dir),
845+
);
842846
const subpaths = Object.keys(published.exports).filter(
843847
(subpath) => subpath !== './package.json',
844848
);
@@ -1126,7 +1130,7 @@ try {
11261130
const given = arg('tarball');
11271131
report.tarball = given ? path.resolve(given) : registry.pack();
11281132
await registry.startRegistry();
1129-
report.version = registry.publish(report.tarball);
1133+
report.version = await registry.publish(report.tarball);
11301134
for (const pm of PACKAGE_MANAGERS) report.packageManagers[pm] = pmVersion(pm) ?? 'not installed';
11311135

11321136
let port = registryPort + 1;

‎scripts/lib/local-registry.mjs‎

Lines changed: 149 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,25 @@
66
* Nothing reaches npmjs for the package under test: the registry has no uplink for the scope, so a
77
* package that failed to publish cannot be quietly satisfied by the real one. Everything else is
88
* proxied to npmjs.
9+
*
10+
* The registry listens on 127.0.0.1, and another local account could hold that port first. So the
11+
* port has to be free before Verdaccio starts, Verdaccio has to stay up, and it has to answer with a
12+
* per-run secret seeded into its storage (the work folder is private to this account) before any
13+
* package command trusts it. The package under test is then checked byte for byte against the
14+
* tarball, both as the registry serves it and as each package manager installs it.
915
*/
1016
import { execFileSync, spawn } from 'node:child_process';
17+
import { createHash, randomBytes } from 'node:crypto';
1118
import {
1219
appendFileSync,
1320
existsSync,
1421
mkdirSync,
1522
readFileSync,
1623
readdirSync,
24+
realpathSync,
1725
writeFileSync,
1826
} from 'node:fs';
27+
import { createServer } from 'node:net';
1928
import path from 'node:path';
2029
import { fileURLToPath } from 'node:url';
2130

@@ -90,8 +99,22 @@ export function wireOverlayInMain(file) {
9099
* Every long-running process it starts is stopped by `stopAll()` and when the process exits.
91100
*/
92101
export function createRegistry({ work, registryPort }) {
93-
const REGISTRY = `http://localhost:${registryPort}`;
102+
// 127.0.0.1 rather than localhost, so every client reaches the one socket Verdaccio holds and not
103+
// a listener on ::1.
104+
const REGISTRY = `http://127.0.0.1:${registryPort}`;
105+
const IDENTITY_PACKAGE = '@pangular-inspector/registry-identity';
94106
const children = new Set();
107+
let registryChild;
108+
let publishedTarball;
109+
110+
/** Fails when the registry was started and has stopped, as nothing else may answer its port. */
111+
function assertRegistryRunning() {
112+
if (registryChild && (registryChild.exitCode !== null || registryChild.signalCode !== null)) {
113+
throw new Error(`the local registry exited:\n${tail(registryChild.logFile)}`);
114+
}
115+
}
116+
117+
const usesRegistry = (env) => env?.npm_config_registry === REGISTRY;
95118

96119
function stopAll() {
97120
for (const child of children) {
@@ -142,6 +165,7 @@ export function createRegistry({ work, registryPort }) {
142165
});
143166

144167
function run(cmd, args, cwd, env = registryEnv(), timeout = 15 * 60_000) {
168+
if (usesRegistry(env)) assertRegistryRunning();
145169
try {
146170
return execFileSync(cmd, args, {
147171
cwd,
@@ -161,6 +185,7 @@ export function createRegistry({ work, registryPort }) {
161185

162186
/** A long-running process in its own group, so stopping it also stops what it started. */
163187
function start(cmd, args, cwd, env, logFile) {
188+
if (usesRegistry(env)) assertRegistryRunning();
164189
const child = spawn(cmd, args, { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
165190
children.add(child);
166191
const log = (chunk) => appendFileSync(logFile, chunk);
@@ -170,19 +195,74 @@ export function createRegistry({ work, registryPort }) {
170195
return child;
171196
}
172197

198+
/**
199+
* Waits for `url` to answer while `child` is running. An answer after `child` exited comes from
200+
* something else on the port, so it fails rather than counting as ready.
201+
*/
173202
async function waitFor(url, child, logFile, seconds) {
174-
for (let tries = 0; tries < seconds; tries++) {
203+
const exited = () => child.exitCode !== null || child.signalCode !== null;
204+
for (let tries = 0; tries < seconds && !exited(); tries++) {
175205
const response = await fetch(url).catch(() => undefined);
176-
if (response?.ok) return response;
177-
if (child.exitCode !== null) break;
206+
if (response?.ok && !exited()) return response;
178207
await new Promise((resolve) => setTimeout(resolve, 1000));
179208
}
180-
throw new Error(`${url} did not answer:\n${tail(logFile)}`);
209+
const why = exited() ? 'its process exited' : `it did not answer in ${seconds}s`;
210+
throw new Error(`${url} is not ready, ${why}:\n${tail(logFile)}`);
211+
}
212+
213+
/** Fails when something already listens on 127.0.0.1:`port`. */
214+
function assertPortFree(port) {
215+
return new Promise((resolve, reject) => {
216+
const probe = createServer();
217+
probe.once('error', (error) =>
218+
reject(
219+
new Error(`127.0.0.1:${port} is taken (${error.code}); set VERIFY_PORT to a free port`),
220+
),
221+
);
222+
probe.listen({ port, host: '127.0.0.1', exclusive: true }, () => probe.close(resolve));
223+
});
224+
}
225+
226+
/**
227+
* Seeds Verdaccio's storage with a package whose readme is a random secret. Only the Verdaccio
228+
* this run started reads that storage, so an answer with the secret proves who holds the port.
229+
*/
230+
function seedIdentity(storageDir) {
231+
const secret = randomBytes(32).toString('hex');
232+
const dir = path.join(storageDir, IDENTITY_PACKAGE);
233+
mkdirSync(dir, { recursive: true });
234+
writeFileSync(
235+
path.join(dir, 'package.json'),
236+
JSON.stringify({
237+
name: IDENTITY_PACKAGE,
238+
versions: {},
239+
'dist-tags': {},
240+
time: {},
241+
_rev: '0-0',
242+
_uplinks: {},
243+
_distfiles: {},
244+
_attachments: {},
245+
readme: secret,
246+
}),
247+
);
248+
return secret;
249+
}
250+
251+
async function checkIdentity(secret) {
252+
const url = `${REGISTRY}/${IDENTITY_PACKAGE.replace('/', '%2f')}`;
253+
const response = await fetch(url).catch(() => undefined);
254+
const body = await response?.json().catch(() => undefined);
255+
if (body?.readme !== secret) {
256+
throw new Error(
257+
`${REGISTRY} answered without this run's secret (${response?.status ?? 'no answer'}): another process may hold the port`,
258+
);
259+
}
181260
}
182261

183262
async function startRegistry() {
184263
const storage = path.join(work, 'registry');
185264
mkdirSync(storage);
265+
const secret = seedIdentity(path.join(storage, 'storage'));
186266
const config = path.join(storage, 'config.yaml');
187267
writeFileSync(
188268
config,
@@ -208,15 +288,19 @@ log: { type: stdout, format: pretty, level: warn }
208288
`,
209289
);
210290
const logFile = path.join(work, 'registry.log');
291+
await assertPortFree(registryPort);
211292
console.log(`starting Verdaccio on ${REGISTRY}`);
212293
const child = start(
213294
'pnpm',
214-
['dlx', 'verdaccio@6', '--config', config, '--listen', `localhost:${registryPort}`],
295+
['dlx', 'verdaccio@6', '--config', config, '--listen', `127.0.0.1:${registryPort}`],
215296
storage,
216297
process.env,
217298
logFile,
218299
);
300+
registryChild = child;
219301
await waitFor(`${REGISTRY}/-/ping`, child, logFile, 180);
302+
await checkIdentity(secret);
303+
assertRegistryRunning();
220304

221305
// Publishing needs a user. Verdaccio creates one on this request and returns its token.
222306
const response = await fetch(`${REGISTRY}/-/user/org.couchdb.user:verify`, {
@@ -228,7 +312,7 @@ log: { type: stdout, format: pretty, level: warn }
228312
if (!token) throw new Error(`Verdaccio returned no token (${response.status})`);
229313
writeFileSync(
230314
path.join(work, '.npmrc'),
231-
`registry=${REGISTRY}\n//localhost:${registryPort}/:_authToken=${token}\n`,
315+
`registry=${REGISTRY}\n//127.0.0.1:${registryPort}/:_authToken=${token}\n`,
232316
);
233317
return child;
234318
}
@@ -265,13 +349,69 @@ log: { type: stdout, format: pretty, level: warn }
265349
return published.version;
266350
}
267351

268-
function publish(tarball) {
352+
/** Publishes `tarball`, then checks the registry serves exactly that file for the version. */
353+
async function publish(tarball) {
269354
const version = checkTarball(tarball);
270355
console.log(`publishing ${manifest.name}@${version} to ${REGISTRY}`);
271356
run('npm', ['publish', tarball, '--registry', REGISTRY, '--access', 'public'], work);
357+
const bytes = readFileSync(tarball);
358+
const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
359+
const packument = await (
360+
await fetch(`${REGISTRY}/${manifest.name.replace('/', '%2f')}`)
361+
).json();
362+
const dist = packument.versions?.[version]?.dist;
363+
if (dist?.integrity !== integrity) {
364+
throw new Error(
365+
`${REGISTRY} lists ${manifest.name}@${version} as ${dist?.integrity}, not the tarball's ${integrity}`,
366+
);
367+
}
368+
const served = Buffer.from(await (await fetch(dist.tarball)).arrayBuffer());
369+
if (!served.equals(bytes)) {
370+
throw new Error(`${dist.tarball} is not the tarball that was published`);
371+
}
372+
assertRegistryRunning();
373+
publishedTarball = tarball;
272374
return version;
273375
}
274376

377+
const sha256 = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
378+
379+
/**
380+
* Checks every file of the published tarball is in `dir/node_modules/<package>` with the same
381+
* content, so a package manager installed the archive this run selected and nothing else.
382+
*/
383+
function verifyInstalled(dir) {
384+
if (!publishedTarball) throw new Error('verifyInstalled runs after publish');
385+
const expected = path.join(work, 'published');
386+
if (!existsSync(expected)) {
387+
mkdirSync(expected);
388+
run('tar', ['-xzf', publishedTarball, '-C', expected], work, process.env);
389+
}
390+
const installed = path.join(dir, 'node_modules', manifest.name);
391+
if (!existsSync(installed)) throw new Error(`${manifest.name} is not in ${dir}/node_modules`);
392+
const installedRoot = realpathSync(installed);
393+
const packageRoot = path.join(expected, 'package');
394+
const differ = [];
395+
const walk = (from) => {
396+
for (const entry of readdirSync(from, { withFileTypes: true })) {
397+
const file = path.join(from, entry.name);
398+
if (entry.isDirectory()) {
399+
walk(file);
400+
continue;
401+
}
402+
const relative = path.relative(packageRoot, file);
403+
const target = path.join(installedRoot, relative);
404+
if (!existsSync(target) || sha256(target) !== sha256(file)) differ.push(relative);
405+
}
406+
};
407+
walk(packageRoot);
408+
if (differ.length) {
409+
throw new Error(
410+
`${installedRoot} does not match the published tarball:\n ${differ.slice(0, 20).join('\n ')}`,
411+
);
412+
}
413+
}
414+
275415
/** The newest version of `name` inside `range`, from npmjs through the registry's proxy. */
276416
function newest(name, range) {
277417
const found = JSON.parse(run('npm', ['view', `${name}@${range}`, 'version', '--json'], work));
@@ -293,6 +433,7 @@ log: { type: stdout, format: pretty, level: warn }
293433
readTarball,
294434
checkTarball,
295435
publish,
436+
verifyInstalled,
296437
newest,
297438
};
298439
}

‎scripts/verify-publish.mjs‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,18 @@ const registryPort = Number(process.env.VERIFY_PORT ?? 4873);
4747
const appPort = registryPort + 1;
4848

4949
const work = mkdtempSync(path.join(tmpdir(), 'pangular-verify-'));
50-
const { run, start, stopAll, waitFor, startRegistry, pack, publish, registryEnv, newest } =
51-
createRegistry({ work, registryPort });
50+
const {
51+
run,
52+
start,
53+
stopAll,
54+
waitFor,
55+
startRegistry,
56+
pack,
57+
publish,
58+
verifyInstalled,
59+
registryEnv,
60+
newest,
61+
} = createRegistry({ work, registryPort });
5262
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => process.exit(130));
5363

5464
const OVERLAY_VITE = `bootstrapApplication(App, appConfig).then(() => {
@@ -78,6 +88,7 @@ async function angularCli(version) {
7888
const app = path.join(work, 'web');
7989
console.log(`installing ${manifest.name}@${version} and devframe from the registry`);
8090
run('npm', ['install', `${manifest.name}@${version}`, 'devframe'], app);
91+
verifyInstalled(app);
8192

8293
const server = path.join(app, 'src/server.ts');
8394
replaceIn(
@@ -118,6 +129,7 @@ async function analog(version) {
118129
const app = path.join(work, 'analog');
119130
console.log(`installing ${manifest.name}@${version} and devframe from the registry`);
120131
run('npm', ['install', `${manifest.name}@${version}`, 'devframe'], app);
132+
verifyInstalled(app);
121133

122134
const config = path.join(app, 'vite.config.ts');
123135
replaceIn(
@@ -190,7 +202,7 @@ try {
190202
const given = arg('tarball');
191203
const tarball = given ? path.resolve(given) : pack();
192204
await startRegistry();
193-
const version = publish(tarball);
205+
const version = await publish(tarball);
194206
const outcome = await SCENARIOS[name](version);
195207
app = outcome.app;
196208
console.log(`\nok ${name}: ${outcome.result}, in ${elapsed()}`);

0 commit comments

Comments
 (0)