package-registry-manager 0.22.11 → 0.23.0
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/README.md +15 -0
- package/package.json +1 -1
- package/src/ci-credential-cycle.mjs +8 -3
- package/src/cli-main.mjs +62 -2
- package/src/crates-api.mjs +4 -5
- package/src/crates-token-rotation.mjs +265 -0
- package/src/crates-tokens.mjs +110 -0
- package/src/github.mjs +1 -0
- package/src/migration-cleanup.mjs +214 -0
- package/src/organization-scan.mjs +37 -10
- package/src/organization-setup.mjs +81 -2
- package/src/plan.mjs +56 -0
- package/src/publishers.mjs +71 -22
- package/src/setup-batch.mjs +1 -0
- package/src/setup.mjs +10 -2
- package/src/sign-in-import.mjs +11 -6
- package/src/workflow-migration.mjs +421 -0
- package/src/workflow-proposal.mjs +80 -33
package/README.md
CHANGED
|
@@ -35,6 +35,21 @@ one browser, groups npm approvals, and uses CI evidence to preserve healthy toke
|
|
|
35
35
|
or verify replacements before revocation. `--secret-name '{REGISTRY}_TOKEN_{REPO}'`
|
|
36
36
|
selects custom credential names.
|
|
37
37
|
|
|
38
|
+
For legacy crates.io consumers, use `setup --org ORG --registry crates-io
|
|
39
|
+
--rotate-token CARGO_TOKEN --execute --browser-import auto`. Rotation preserves
|
|
40
|
+
scopes and GitHub visibility, stores an expiry companion secret, verifies every
|
|
41
|
+
consumer in CI before revocation, and lists migration targets. Ordinary crates.io
|
|
42
|
+
setup uses trusted publishing. `inspect --org ORG --token-expiry-days 7` opens the
|
|
43
|
+
private browser token inventory for advance expiry warnings. See the
|
|
44
|
+
[rotation configuration](../README.md#configuration) for matching and failure behavior.
|
|
45
|
+
|
|
46
|
+
`setup --org ORG --registry crates-io --migrate --execute --browser-import auto`
|
|
47
|
+
proposes a reviewed conversion of existing jobs and reusable callers. After merging,
|
|
48
|
+
rerun it to register trust, and rerun after a real OIDC release to remove verified
|
|
49
|
+
legacy secrets. Shared organization secrets remain while other consumers exist.
|
|
50
|
+
The same flow supports `--registry npm` and `--registry pypi`; see the
|
|
51
|
+
[migration workflow](../README.md#configuration) for supported layouts and safeguards.
|
|
52
|
+
|
|
38
53
|
Account scans print progress to stderr (`[37/169] owner/repository`) and stream
|
|
39
54
|
text inspection results as each repository finishes. JSON inspection remains one
|
|
40
55
|
report on stdout. Repository, manifest, registry lookup, and release-log failures are
|
package/package.json
CHANGED
|
@@ -48,13 +48,18 @@ export function completeRotation(state, repos, secret, id) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** CI drives rotation. Uncertain tests preserve every existing credential. */
|
|
51
|
-
export async function cycleCredential(adapter) {
|
|
51
|
+
export async function cycleCredential(adapter, { force = false } = {}) {
|
|
52
52
|
const health = await adapter.health();
|
|
53
|
-
if (health.status === "ok") {
|
|
53
|
+
if (!force && health.status === "ok") {
|
|
54
54
|
return { status: "ok", changed: false };
|
|
55
55
|
}
|
|
56
56
|
const previous = await adapter.metadata();
|
|
57
|
-
if (
|
|
57
|
+
if (
|
|
58
|
+
!force &&
|
|
59
|
+
health.status === "unknown" &&
|
|
60
|
+
previous &&
|
|
61
|
+
previous.present !== false
|
|
62
|
+
) {
|
|
58
63
|
const tested = await adapter.test();
|
|
59
64
|
if (tested.status === "ok") {
|
|
60
65
|
return { status: "ok", changed: false };
|
package/src/cli-main.mjs
CHANGED
|
@@ -35,6 +35,9 @@ Global options:
|
|
|
35
35
|
--repository <path> Inspect only this repository (default: widest scope)
|
|
36
36
|
--org <org> | --user <login> Scan repositories through gh-manager
|
|
37
37
|
--secret-name <template> Secret name; supports {REGISTRY}, {REPO}, {ORG}
|
|
38
|
+
--rotate-token <secret> Explicitly replace a legacy crates.io token
|
|
39
|
+
--migrate Convert token jobs in PRs, then verify OIDC and clean up secrets
|
|
40
|
+
--token-expiry-days <N> Read browser token inventory in account scans
|
|
38
41
|
--format <text|json> Output format (default: text)
|
|
39
42
|
--offline Do not look up packages on registries
|
|
40
43
|
--verbose Print command details and output, and
|
|
@@ -111,6 +114,9 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
111
114
|
org: { type: "string" },
|
|
112
115
|
user: { type: "string" },
|
|
113
116
|
"secret-name": { type: "string" },
|
|
117
|
+
"rotate-token": { type: "string" },
|
|
118
|
+
migrate: { type: "boolean", default: false },
|
|
119
|
+
"token-expiry-days": { type: "string" },
|
|
114
120
|
format: { type: "string", default: "text" },
|
|
115
121
|
verbose: { type: "boolean", default: false },
|
|
116
122
|
offline: { type: "boolean", default: false },
|
|
@@ -174,6 +180,47 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
174
180
|
}
|
|
175
181
|
const repository = path.resolve(values.repository ?? ".");
|
|
176
182
|
const command = positionals[0];
|
|
183
|
+
const rotateToken = values["rotate-token"];
|
|
184
|
+
if (
|
|
185
|
+
values.migrate &&
|
|
186
|
+
(command !== "setup" ||
|
|
187
|
+
rotateToken !== undefined ||
|
|
188
|
+
!values.registry?.length ||
|
|
189
|
+
(values.registry ?? []).some(
|
|
190
|
+
(registry) => !["crates-io", "npm", "pypi"].includes(registry),
|
|
191
|
+
))
|
|
192
|
+
) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"--migrate requires setup with crates-io, npm or pypi and cannot use --rotate-token",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
rotateToken !== undefined &&
|
|
199
|
+
(command !== "setup" ||
|
|
200
|
+
(values.registry ?? []).map(parseRegistry).join() !== "crates-io" ||
|
|
201
|
+
!/^[A-Z_][A-Z0-9_]{0,244}$/.test(rotateToken) ||
|
|
202
|
+
rotateToken.startsWith("GITHUB_"))
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
"--rotate-token requires setup --registry crates-io and a valid secret name",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const tokenExpiryDays =
|
|
209
|
+
values["token-expiry-days"] === undefined
|
|
210
|
+
? undefined
|
|
211
|
+
: Number(values["token-expiry-days"]);
|
|
212
|
+
if (
|
|
213
|
+
tokenExpiryDays !== undefined &&
|
|
214
|
+
(!/^\d+$/.test(values["token-expiry-days"]) ||
|
|
215
|
+
!Number.isInteger(tokenExpiryDays) ||
|
|
216
|
+
tokenExpiryDays < 0 ||
|
|
217
|
+
tokenExpiryDays > 365 ||
|
|
218
|
+
!(values.org || values.user))
|
|
219
|
+
) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
"--token-expiry-days requires an account scan and an integer between 0 and 365",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
177
224
|
if (values.org || values.user) {
|
|
178
225
|
if (values.org && values.user) {
|
|
179
226
|
throw new Error("--org and --user are mutually exclusive");
|
|
@@ -184,7 +231,10 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
184
231
|
if (!["inspect", "plan", "setup"].includes(command)) {
|
|
185
232
|
throw new Error(`unknown command '${command}'`);
|
|
186
233
|
}
|
|
187
|
-
if (
|
|
234
|
+
if (
|
|
235
|
+
command === "setup" &&
|
|
236
|
+
((!values.all && !rotateToken && !values.migrate) || values.package)
|
|
237
|
+
) {
|
|
188
238
|
throw new Error("account setup requires --all without --package");
|
|
189
239
|
}
|
|
190
240
|
if (values["dry-run"] && values.execute) {
|
|
@@ -215,6 +265,9 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
215
265
|
yes: values.yes,
|
|
216
266
|
noBrowser: values["no-browser"],
|
|
217
267
|
secretName: values["secret-name"],
|
|
268
|
+
rotateToken,
|
|
269
|
+
migrate: values.migrate,
|
|
270
|
+
tokenExpiryDays,
|
|
218
271
|
package: values.package,
|
|
219
272
|
browser: values.browser,
|
|
220
273
|
browserOptions,
|
|
@@ -228,6 +281,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
228
281
|
workflow: values.workflow,
|
|
229
282
|
publisherEnvironment: values.environment,
|
|
230
283
|
addPublishJob: values["add-publish-job"],
|
|
284
|
+
migrate: values.migrate,
|
|
231
285
|
manual: values.manual,
|
|
232
286
|
verifyRelease: values["verify-release"],
|
|
233
287
|
},
|
|
@@ -269,6 +323,8 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
269
323
|
),
|
|
270
324
|
});
|
|
271
325
|
const planOptions = {
|
|
326
|
+
rotateToken,
|
|
327
|
+
migrate: values.migrate,
|
|
272
328
|
ref: values.ref,
|
|
273
329
|
verifyRelease: values["verify-release"],
|
|
274
330
|
manual: values.manual,
|
|
@@ -327,8 +383,12 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
327
383
|
openWith: nonEmpty(values["open-with"], "--open-with"),
|
|
328
384
|
keepSession: values["keep-session"],
|
|
329
385
|
secretName: values["secret-name"],
|
|
386
|
+
rotateToken,
|
|
387
|
+
migrate: values.migrate,
|
|
330
388
|
quietBrowser: selected.some((plan) =>
|
|
331
|
-
plan.steps.some((step) =>
|
|
389
|
+
plan.steps.some((step) =>
|
|
390
|
+
["manage-registry-token", "rotate-crates-token"].includes(step.id),
|
|
391
|
+
),
|
|
332
392
|
),
|
|
333
393
|
browser: values.browser,
|
|
334
394
|
browserOptions,
|
package/src/crates-api.mjs
CHANGED
|
@@ -104,15 +104,16 @@ export async function readSession(page) {
|
|
|
104
104
|
* unverified one stops here with the settings link.
|
|
105
105
|
*/
|
|
106
106
|
export async function signIn(page, options = {}) {
|
|
107
|
+
const log = options.log ?? console.log;
|
|
107
108
|
await page.goto(CRATES_IO_URL);
|
|
108
109
|
let session = await readSession(page);
|
|
109
110
|
const signedInBefore = session.signedIn;
|
|
110
111
|
if (!session.signedIn) {
|
|
111
|
-
|
|
112
|
+
log(
|
|
112
113
|
" Not signed in to crates.io; finish the GitHub sign-in in the automated browser.",
|
|
113
114
|
);
|
|
114
115
|
if (!(await page.evaluate(LOGIN_CLICK_SCRIPT))) {
|
|
115
|
-
|
|
116
|
+
log(' Click "Log in with GitHub" on crates.io.');
|
|
116
117
|
}
|
|
117
118
|
const deadline = Date.now() + (options.timeoutMs ?? SIGN_IN_TIMEOUT_MS);
|
|
118
119
|
const interval = options.pollIntervalMs ?? 2_000;
|
|
@@ -124,9 +125,7 @@ export async function signIn(page, options = {}) {
|
|
|
124
125
|
session = await readSession(page);
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
-
` Signed in to crates.io as ${session.login ?? "your account"}.`,
|
|
129
|
-
);
|
|
128
|
+
log(` Signed in to crates.io as ${session.login ?? "your account"}.`);
|
|
130
129
|
if (!session.emailVerified) {
|
|
131
130
|
const sent = session.emailSent
|
|
132
131
|
? " A verification email was sent; follow its link,"
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { githubServices } from "./github.mjs";
|
|
4
|
+
import {
|
|
5
|
+
cycleCredential,
|
|
6
|
+
secretName,
|
|
7
|
+
trackedTokenIds,
|
|
8
|
+
recordTokenId,
|
|
9
|
+
completeRotation,
|
|
10
|
+
} from "./ci-credential-cycle.mjs";
|
|
11
|
+
import { pageFetchScript, runCratesApiStep } from "./crates-api.mjs";
|
|
12
|
+
import {
|
|
13
|
+
listCratesTokens,
|
|
14
|
+
selectCratesToken,
|
|
15
|
+
replacementTokenRequest,
|
|
16
|
+
} from "./crates-tokens.mjs";
|
|
17
|
+
import { AUTH_FAILURE_PATTERNS } from "./publishing-policy.mjs";
|
|
18
|
+
|
|
19
|
+
/** Explicit maintenance of existing legacy secrets; ordinary crates setup uses OIDC. */
|
|
20
|
+
export async function rotateCratesToken(session) {
|
|
21
|
+
const { options, plan } = session;
|
|
22
|
+
const secret = secretName(
|
|
23
|
+
options.rotateToken,
|
|
24
|
+
"crates-io",
|
|
25
|
+
`${plan.repository.github_owner}/${plan.repository.github_repository}`,
|
|
26
|
+
);
|
|
27
|
+
if (
|
|
28
|
+
options.noBrowser ||
|
|
29
|
+
options.browserOptions?.attach ||
|
|
30
|
+
options.browserOptions?.importScope === "full"
|
|
31
|
+
) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"token rotation requires a dedicated profile with domain-scoped sign-in import",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const settings =
|
|
37
|
+
JSON.parse(
|
|
38
|
+
await readFile(
|
|
39
|
+
path.join(options.repository, ".package-registry-manager.json"),
|
|
40
|
+
"utf8",
|
|
41
|
+
).catch((error) => {
|
|
42
|
+
if (error.code === "ENOENT") {
|
|
43
|
+
return "{}";
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}),
|
|
47
|
+
).tokens?.["crates-io"] ?? {};
|
|
48
|
+
if (settings.level && !["repo", "org"].includes(settings.level)) {
|
|
49
|
+
throw new Error("token secret level must be repo or org");
|
|
50
|
+
}
|
|
51
|
+
const github = options.github ?? githubServices(options);
|
|
52
|
+
const owner = plan.repository.github_owner;
|
|
53
|
+
const slug = `${owner}/${plan.repository.github_repository}`;
|
|
54
|
+
if (!owner || !plan.repository.github_repository) {
|
|
55
|
+
throw new Error("token rotation requires GitHub repository coordinates");
|
|
56
|
+
}
|
|
57
|
+
const key = `${settings.level === "repo" ? slug : owner}:${secret}`;
|
|
58
|
+
if (options.rotatedSecrets?.has(key)) {
|
|
59
|
+
const outcome = options.rotatedSecrets.get(key);
|
|
60
|
+
if (!outcome) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"previous rotation could not be verified; credentials retained",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
session.outcome = outcome;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
options.rotatedSecrets?.set(key, null);
|
|
69
|
+
const targets =
|
|
70
|
+
settings.level === "repo"
|
|
71
|
+
? [slug]
|
|
72
|
+
: (options.secretRepositories?.[`crates-io:${secret}`] ?? [slug]);
|
|
73
|
+
const scope = settings.level === "repo" ? { repo: slug } : { org: owner };
|
|
74
|
+
let metadata;
|
|
75
|
+
try {
|
|
76
|
+
metadata = await github.secrets(scope).getMetadata(secret);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (![403, 404, 422].includes(error.status)) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
metadata ??= await github.secrets({ repo: slug }).getMetadata(secret);
|
|
83
|
+
if (!metadata) {
|
|
84
|
+
throw new Error(`existing secret ${secret} was not found`);
|
|
85
|
+
}
|
|
86
|
+
// Keep access unchanged and test consumers beyond the current plan selection too.
|
|
87
|
+
const granted =
|
|
88
|
+
metadata.selected_repositories?.map((repo) => repo.full_name) ?? [];
|
|
89
|
+
const consumers = [...new Set([...targets, ...granted])];
|
|
90
|
+
if (
|
|
91
|
+
scope.org &&
|
|
92
|
+
["all", "private"].includes(metadata.visibility) &&
|
|
93
|
+
!options.accountScan
|
|
94
|
+
) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"rotation of an all/private organization secret requires --org to discover every consumer",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (options.accountScan && options.scanErrors) {
|
|
100
|
+
throw new Error("token rotation requires a complete account scan");
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
!options.yes &&
|
|
104
|
+
!/^y(?:es)?$/i.test(
|
|
105
|
+
await session.prompt(
|
|
106
|
+
"Replace the legacy token, verify consumers in CI, then revoke the old token? [y/N] ",
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
) {
|
|
110
|
+
throw new Error("credential creation declined");
|
|
111
|
+
}
|
|
112
|
+
await runCratesApiStep(session, { id: "crates-sign-in" });
|
|
113
|
+
const page = await session.automatedPage(options.browserOptions, true);
|
|
114
|
+
const previous = selectCratesToken(
|
|
115
|
+
await listCratesTokens(page),
|
|
116
|
+
secret,
|
|
117
|
+
metadata,
|
|
118
|
+
settings.token_id,
|
|
119
|
+
);
|
|
120
|
+
const request = replacementTokenRequest(previous, settings.expiry_days ?? 30);
|
|
121
|
+
const stateFile =
|
|
122
|
+
options.browserProfile &&
|
|
123
|
+
path.join(path.dirname(options.browserProfile), "credential-ids.json");
|
|
124
|
+
const state = stateFile
|
|
125
|
+
? JSON.parse(
|
|
126
|
+
await readFile(stateFile, "utf8").catch((error) => {
|
|
127
|
+
if (error.code === "ENOENT") {
|
|
128
|
+
return "{}";
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
: {};
|
|
134
|
+
const persist = async () => {
|
|
135
|
+
if (stateFile) {
|
|
136
|
+
await mkdir(path.dirname(stateFile), { recursive: true });
|
|
137
|
+
await writeFile(stateFile, JSON.stringify(state));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const previousId = String(previous.id);
|
|
141
|
+
const retained = Object.entries(state).some(
|
|
142
|
+
([entry, ids]) =>
|
|
143
|
+
!consumers.some((repo) => entry === `${repo}:${secret}`) &&
|
|
144
|
+
ids.includes(previousId),
|
|
145
|
+
);
|
|
146
|
+
if (options.verbose) {
|
|
147
|
+
console.error(
|
|
148
|
+
`Rotate crates.io token ${previousId}; verify ${consumers.join(", ")}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const result = await cycleCredential(
|
|
152
|
+
{
|
|
153
|
+
health: async () => ({ status: "ok" }),
|
|
154
|
+
metadata: async () => ({
|
|
155
|
+
token_id: retained ? undefined : previousId,
|
|
156
|
+
token_ids: trackedTokenIds(state, consumers, secret),
|
|
157
|
+
}),
|
|
158
|
+
create: async () => {
|
|
159
|
+
const created = await page.evaluate(
|
|
160
|
+
pageFetchScript("PUT", "/api/v1/me/tokens", request),
|
|
161
|
+
);
|
|
162
|
+
const token = created.body?.api_token;
|
|
163
|
+
if (
|
|
164
|
+
created.status !== 200 ||
|
|
165
|
+
!token?.token ||
|
|
166
|
+
token.id === undefined ||
|
|
167
|
+
token.id === null
|
|
168
|
+
) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`crates.io token creation failed (${created.status})`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
id: String(token.id),
|
|
175
|
+
value: token.token,
|
|
176
|
+
expires_at: token.expired_at ?? request.api_token.expired_at,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
ensure: async (credential) => {
|
|
180
|
+
const broad =
|
|
181
|
+
scope.org && ["all", "private"].includes(metadata.visibility);
|
|
182
|
+
const stored = broad
|
|
183
|
+
? {
|
|
184
|
+
...(await github.secrets(scope).set(secret, credential.value, {
|
|
185
|
+
visibility: metadata.visibility,
|
|
186
|
+
expiresAt: credential.expires_at,
|
|
187
|
+
})),
|
|
188
|
+
path: "organization",
|
|
189
|
+
}
|
|
190
|
+
: await github.secrets(scope).ensure(secret, {
|
|
191
|
+
...(scope.org
|
|
192
|
+
? { repos: consumers, visibility: "selected" }
|
|
193
|
+
: {}),
|
|
194
|
+
health: { status: "auth-failing" },
|
|
195
|
+
rotateBeforeMs: 0,
|
|
196
|
+
acquire: async () => ({
|
|
197
|
+
value: credential.value,
|
|
198
|
+
expiresAt: credential.expires_at,
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
for (const repo of consumers) {
|
|
202
|
+
if (
|
|
203
|
+
stored.path === "organization" &&
|
|
204
|
+
(await github.secrets({ repo }).getMetadata(secret))
|
|
205
|
+
) {
|
|
206
|
+
await github.secrets({ repo }).set(secret, credential.value, {
|
|
207
|
+
expiresAt: credential.expires_at,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (stored.valueChanged === false) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
"gh-manager did not store the replacement credential",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
recordTokenId(state, consumers, secret, credential.id);
|
|
217
|
+
await persist();
|
|
218
|
+
return stored;
|
|
219
|
+
},
|
|
220
|
+
test: async () => {
|
|
221
|
+
for (const repo of consumers) {
|
|
222
|
+
const result = await github.health.test(secret, {
|
|
223
|
+
scope: { repo },
|
|
224
|
+
failurePatterns: AUTH_FAILURE_PATTERNS["crates-io"],
|
|
225
|
+
});
|
|
226
|
+
if (result.status !== "ok") {
|
|
227
|
+
return { status: "unknown" };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { status: "ok" };
|
|
231
|
+
},
|
|
232
|
+
revoke: async (id) => {
|
|
233
|
+
const revoked = await page.evaluate(
|
|
234
|
+
pageFetchScript(
|
|
235
|
+
"DELETE",
|
|
236
|
+
`/api/v1/me/tokens/${encodeURIComponent(id)}`,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
if (![200, 204].includes(revoked.status)) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`crates.io token revocation failed (${revoked.status})`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
revoked: async (id) =>
|
|
246
|
+
!(await listCratesTokens(page)).some(
|
|
247
|
+
(token) => String(token.id) === id,
|
|
248
|
+
),
|
|
249
|
+
},
|
|
250
|
+
{ force: true },
|
|
251
|
+
);
|
|
252
|
+
completeRotation(state, consumers, secret, result.token_id);
|
|
253
|
+
await persist();
|
|
254
|
+
session.outcome = {
|
|
255
|
+
status: "configured",
|
|
256
|
+
secret,
|
|
257
|
+
secret_scope: result.path,
|
|
258
|
+
fallback_reason: result.fallbackReason,
|
|
259
|
+
token_id: result.token_id,
|
|
260
|
+
};
|
|
261
|
+
options.rotatedSecrets?.set(key, session.outcome);
|
|
262
|
+
console.log(
|
|
263
|
+
`Migrate these repositories to crates.io trusted publishing before deleting the legacy token secret: ${consumers.join(", ")}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { pageFetchScript } from "./crates-api.mjs";
|
|
2
|
+
|
|
3
|
+
/** Fetch private token metadata through the authenticated browser, without values. */
|
|
4
|
+
export async function listCratesTokens(page) {
|
|
5
|
+
const result = await page.evaluate(
|
|
6
|
+
pageFetchScript("GET", "/api/v1/me/tokens?expired_days=365"),
|
|
7
|
+
);
|
|
8
|
+
if (result.status !== 200 || !Array.isArray(result.body?.api_tokens)) {
|
|
9
|
+
throw new Error(`crates.io token inventory unavailable (${result.status})`);
|
|
10
|
+
}
|
|
11
|
+
return result.body.api_tokens.map(
|
|
12
|
+
({ id, name, created_at, expired_at, crate_scopes, endpoint_scopes }) => ({
|
|
13
|
+
id,
|
|
14
|
+
name,
|
|
15
|
+
created_at,
|
|
16
|
+
expired_at,
|
|
17
|
+
crate_scopes,
|
|
18
|
+
endpoint_scopes,
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** GitHub cannot reveal a secret value: refuse absent or ambiguous metadata matches. */
|
|
24
|
+
export function selectCratesToken(tokens, secret, metadata, id) {
|
|
25
|
+
const updated = Date.parse(metadata?.updated_at ?? "");
|
|
26
|
+
let matches;
|
|
27
|
+
if (id !== undefined && id !== null) {
|
|
28
|
+
matches = tokens.filter((token) => String(token.id) === String(id));
|
|
29
|
+
} else {
|
|
30
|
+
const named = tokens.filter(
|
|
31
|
+
(token) => token.name?.toUpperCase() === secret.toUpperCase(),
|
|
32
|
+
);
|
|
33
|
+
matches = named.length ? named : tokens;
|
|
34
|
+
if (Number.isFinite(updated)) {
|
|
35
|
+
matches = matches.filter(
|
|
36
|
+
(token) => Math.abs(Date.parse(token.created_at) - updated) <= 300_000,
|
|
37
|
+
);
|
|
38
|
+
} else if (!named.length) {
|
|
39
|
+
matches = [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (
|
|
43
|
+
matches.length !== 1 ||
|
|
44
|
+
matches[0].id === undefined ||
|
|
45
|
+
matches[0].id === null
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`cannot uniquely identify crates.io token for ${secret}; configure tokens.crates-io.token_id`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return matches[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Preserve even legacy null scopes; only the name and expiry change. */
|
|
55
|
+
export function replacementTokenRequest(previous, days = 30, now = Date.now()) {
|
|
56
|
+
if (!Number.isInteger(days) || days < 1 || days > 90) {
|
|
57
|
+
throw new Error("token expiry_days must be between 1 and 90");
|
|
58
|
+
}
|
|
59
|
+
for (const key of ["crate_scopes", "endpoint_scopes"]) {
|
|
60
|
+
if (
|
|
61
|
+
previous[key] !== null &&
|
|
62
|
+
(!Array.isArray(previous[key]) ||
|
|
63
|
+
previous[key].some((scope) => typeof scope !== "string"))
|
|
64
|
+
) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`crates.io token has no usable ${key}; refusing to broaden scopes`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
api_token: {
|
|
72
|
+
name: previous.name,
|
|
73
|
+
crate_scopes: previous.crate_scopes,
|
|
74
|
+
endpoint_scopes: previous.endpoint_scopes,
|
|
75
|
+
expired_at: new Date(now + days * 86_400_000).toISOString(),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Include expired tokens and the inclusive lead-time boundary. */
|
|
81
|
+
export function tokenExpiryFindings(
|
|
82
|
+
tokens,
|
|
83
|
+
account,
|
|
84
|
+
days = 7,
|
|
85
|
+
now = Date.now(),
|
|
86
|
+
) {
|
|
87
|
+
if (!Number.isInteger(days) || days < 0 || days > 365) {
|
|
88
|
+
throw new Error("--token-expiry-days must be an integer between 0 and 365");
|
|
89
|
+
}
|
|
90
|
+
return tokens.flatMap((token) => {
|
|
91
|
+
const expiry = Date.parse(token.expired_at ?? "");
|
|
92
|
+
return Number.isFinite(expiry) && expiry <= now + days * 86_400_000
|
|
93
|
+
? [
|
|
94
|
+
{
|
|
95
|
+
repository: account,
|
|
96
|
+
registry: "crates-io",
|
|
97
|
+
type:
|
|
98
|
+
expiry <= now
|
|
99
|
+
? "registry-token-expired"
|
|
100
|
+
: "registry-token-expiring",
|
|
101
|
+
evidence: {
|
|
102
|
+
token_id: token.id,
|
|
103
|
+
name: token.name,
|
|
104
|
+
expires_at: token.expired_at,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
: [];
|
|
109
|
+
});
|
|
110
|
+
}
|