-
Notifications
You must be signed in to change notification settings - Fork 63
Expand file tree
/
Copy pathcheck-package-drift.ts
More file actions
313 lines (283 loc) · 11.6 KB
/
Copy pathcheck-package-drift.ts
File metadata and controls
313 lines (283 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
#!/usr/bin/env npx ts-node
/**
* Compares live npm registry state against the expected state declared in
* src/config/packageAccess.ts and prints a drift report plus a remediation
* plan of npm CLI commands for a maintainer to run in a single interactive
* 2FA-authenticated session (see "npm & PyPI Package Publishing Access" in
* the README).
*
* Read-only: this script never mutates anything. All npm write operations
* require an interactive 2FA challenge (since August 2026), which is why the
* remediation is a human-executed plan instead of automation. PyPI has no
* management API at all and is not checked here.
*
* Run with: NPM_TOKEN=<read-only granular access token> npx ts-node scripts/check-package-drift.ts
*
* - Without NPM_TOKEN: prints a skip notice and exits 0 (so CI without the
* secret is a graceful no-op, mirroring the optional Discord credentials).
* - With NPM_TOKEN: exits 1 when drift is found, 0 when clean.
*/
import {
NPM_ORG,
NPM_PACKAGES,
NPM_DEFAULT_POLICY,
getNpmPackageAccess,
getExpectedNpmOrgMembers,
} from '../src/config/packageAccess';
const REGISTRY = 'https://registry.npmjs.org';
const REQUEST_DELAY_MS = 150;
const token = process.env.NPM_TOKEN;
interface Drift {
description: string;
/** Remediation lines; lines starting with '#' are guidance, not commands */
commands: readonly string[];
}
const drifts: Drift[] = [];
const warnings: string[] = [];
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function registryGet(path: string): Promise<{ status: number; body: unknown }> {
const url = `${REGISTRY}${path}`;
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) await sleep(2000 * attempt);
try {
const response = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
});
if (response.status >= 500) {
lastError = new Error(`HTTP ${response.status} from ${url}`);
continue;
}
let body: unknown = null;
try {
body = await response.json();
} catch {
// Non-JSON body (e.g. empty 404) — leave as null
}
return { status: response.status, body };
} catch (e) {
lastError = e;
}
}
throw lastError;
}
/**
* Escape a package name for use as a registry URL path segment. Follows the
* npm CLI's convention for scoped packages: keep the leading '@' literal and
* percent-encode every '/'.
*/
function escapePackageName(packageName: string): string {
return packageName.replace(/\//g, '%2F');
}
function diffSets(
expected: readonly string[],
actual: readonly string[]
): { missing: string[]; unexpected: string[] } {
const expectedSet = new Set(expected);
const actualSet = new Set(actual);
return {
missing: expected.filter((e) => !actualSet.has(e)).sort(),
unexpected: actual.filter((a) => !expectedSet.has(a)).sort(),
};
}
async function checkOrgMembership(): Promise<void> {
const { status, body } = await registryGet(`/-/org/${NPM_ORG}/user`);
if (status !== 200 || typeof body !== 'object' || body === null) {
warnings.push(
`Could not read org roster (GET /-/org/${NPM_ORG}/user returned ${status}). ` +
`The token likely lacks organization read access — org membership was NOT checked.`
);
return;
}
const roster = body as Record<string, string>;
const actual = Object.keys(roster);
if (actual.length === 0) {
warnings.push(
`Org roster came back empty — unauthenticated requests see an empty roster, ` +
`so the token likely lacks organization access. Org membership was NOT checked.`
);
return;
}
const { missing, unexpected } = diffSets(getExpectedNpmOrgMembers(), actual);
for (const user of missing) {
drifts.push({
description: `Org member missing: "${user}" is expected in the "${NPM_ORG}" org but is not a member`,
commands: [`npm org set ${NPM_ORG} ${user} developer`],
});
}
for (const user of unexpected) {
drifts.push({
description:
`Unexpected org member: "${user}" (role: ${roster[user]}) is in the "${NPM_ORG}" org ` +
`but not declared in packageAccess.ts/users.ts`,
commands: [
`# Either add npm: '${user}' to the right member in src/config/users.ts (or UNMAPPED_NPM_USERS), or remove them:`,
`npm org rm ${NPM_ORG} ${user}`,
],
});
}
}
async function listOrgPackages(): Promise<string[]> {
const { status, body } = await registryGet(`/-/org/${NPM_ORG}/package`);
if (status === 200 && typeof body === 'object' && body !== null) {
return Object.keys(body as Record<string, string>).sort();
}
warnings.push(
`Could not enumerate org packages (GET /-/org/${NPM_ORG}/package returned ${status}); ` +
`falling back to the ${NPM_PACKAGES.length} explicitly declared packages.`
);
return NPM_PACKAGES.map((p) => p.package);
}
/** Extract candidate trusted-publisher configs from a /trust response, defensively. */
function extractTrustConfigs(body: unknown): Array<Record<string, unknown>> {
if (body === null || typeof body !== 'object') return [];
if (Array.isArray(body)) return body.filter((c) => typeof c === 'object' && c !== null);
const obj = body as Record<string, unknown>;
for (const key of ['objects', 'configurations', 'trustedPublishers', 'trust']) {
if (Array.isArray(obj[key])) {
return (obj[key] as unknown[]).filter(
(c): c is Record<string, unknown> => typeof c === 'object' && c !== null
);
}
}
return [obj];
}
async function checkPackage(packageName: string): Promise<void> {
const expected = getNpmPackageAccess(packageName);
const isExplicit = NPM_PACKAGES.some((p) => p.package === packageName);
// 1) Maintainers, from the public package document
const { status, body } = await registryGet(`/${escapePackageName(packageName)}`);
if (status !== 200 || typeof body !== 'object' || body === null) {
warnings.push(`Could not read package document for ${packageName} (HTTP ${status}).`);
return;
}
const packument = body as {
maintainers?: Array<{ name?: string }>;
'dist-tags'?: Record<string, string>;
versions?: Record<string, { _npmUser?: { trustedPublisher?: unknown } }>;
};
const actualMaintainers = (packument.maintainers ?? [])
.map((m) => m.name)
.filter((name): name is string => typeof name === 'string');
const { missing, unexpected } = diffSets(expected.maintainers, actualMaintainers);
for (const user of missing) {
drifts.push({
description: `Maintainer missing on ${packageName}: "${user}"`,
commands: [`npm owner add ${user} ${packageName}`],
});
}
for (const user of unexpected) {
drifts.push({
description:
`Unexpected maintainer on ${packageName}: "${user}" ` +
`(not in its ${isExplicit ? 'declared maintainers' : 'default-policy maintainers'})`,
commands: [
`# Either declare "${user}" for this package in src/config/packageAccess.ts, or remove them:`,
`npm owner rm ${user} ${packageName}`,
],
});
}
// 2) Trusted publishing of the latest release (default policy)
const latestVersion = packument['dist-tags']?.latest;
const latest = latestVersion ? packument.versions?.[latestVersion] : undefined;
if (NPM_DEFAULT_POLICY.requireTrustedPublishing && latest && !latest._npmUser?.trustedPublisher) {
drifts.push({
description:
`${packageName}@${latestVersion} was not published via trusted publishing ` +
`(policy: all org packages publish via OIDC)`,
commands: [
`# Configure a trusted publisher for ${packageName} (web UI: package Settings -> Trusted publishing,`,
`# or \`npm trust\` on npm >= 11.15.0), then stop using publish tokens for it.`,
],
});
}
// 3) Declared trusted-publisher configuration (explicit packages only)
if (expected.trustedPublisher) {
const want = expected.trustedPublisher;
const trust = await registryGet(`/-/package/${escapePackageName(packageName)}/trust`);
if (trust.status === 401 || trust.status === 403) {
warnings.push(
`Cannot read trusted-publisher config for ${packageName} (HTTP ${trust.status}); ` +
`token lacks permission — declared publisher (${want.repository} ${want.workflow}) was NOT verified.`
);
} else if (trust.status === 404 || extractTrustConfigs(trust.body).length === 0) {
drifts.push({
description: `No trusted publisher configured on ${packageName} (expected ${want.repository} via ${want.workflow})`,
commands: [
`# Configure trusted publishing for ${packageName}: GitHub Actions, repository ${want.repository},`,
`# workflow ${want.workflow} (web UI: package Settings -> Trusted publishing, or \`npm trust\`).`,
],
});
} else if (trust.status === 200) {
const configs = extractTrustConfigs(trust.body);
const matches = configs.some((c) => {
const text = JSON.stringify(c);
const repoOk =
text.includes(want.repository) ||
(text.includes(want.repository.split('/')[0]) &&
text.includes(want.repository.split('/')[1]));
const workflowFile = want.workflow.split('/').pop() ?? want.workflow;
return repoOk && text.includes(workflowFile);
});
if (!matches) {
drifts.push({
description:
`Trusted publisher mismatch on ${packageName}: expected ${want.repository} via ${want.workflow}, ` +
`live config differs: ${JSON.stringify(configs)}`,
commands: [
`# Update trusted publishing for ${packageName} to repository ${want.repository},`,
`# workflow ${want.workflow} (web UI: package Settings -> Trusted publishing, or \`npm trust\`).`,
],
});
}
}
}
}
async function main(): Promise<void> {
if (!token) {
console.log('NPM_TOKEN is not set — skipping npm package access drift check.');
console.log(
'Provide a read-only granular access token with organization read access to enable it.'
);
process.exit(0);
}
console.log(`Checking npm package access drift for org "${NPM_ORG}"...\n`);
await checkOrgMembership();
const packages = await listOrgPackages();
console.log(`Checking ${packages.length} packages...`);
const declared = new Set(NPM_PACKAGES.map((p) => p.package));
const notInOrg = [...declared].filter((p) => !packages.includes(p));
for (const pkg of notInOrg) {
warnings.push(`Declared package ${pkg} was not found in the org package list.`);
}
for (const pkg of packages) {
await checkPackage(pkg);
await sleep(REQUEST_DELAY_MS);
}
// Report
if (warnings.length > 0) {
console.log('\n--- Warnings (not drift) ---');
for (const warning of warnings) console.log(` ! ${warning}`);
}
if (drifts.length === 0) {
console.log('\nNo drift detected. Live npm state matches src/config/packageAccess.ts.');
process.exit(0);
}
console.log(`\n--- Drift report (${drifts.length} finding${drifts.length === 1 ? '' : 's'}) ---`);
for (const drift of drifts) console.log(` ✗ ${drift.description}`);
console.log('\n--- Remediation plan ---');
console.log('# Review, then run in ONE interactive npm session (logged in as an org owner).');
console.log('# Approve a 2FA challenge on npmjs.com and choose "Don\'t ask again for 5 minutes"');
console.log('# to batch these; add `sleep 2` between commands in longer batches.');
console.log('# See "npm & PyPI Package Publishing Access" in the README.\n');
for (const drift of drifts) {
for (const command of drift.commands) console.log(command);
}
process.exit(1);
}
main().catch((e) => {
console.error(`Drift check failed: ${e}`);
process.exit(1);
});