package-registry-manager 0.24.0 → 0.25.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 +7 -0
- package/package.json +3 -3
- package/src/automation.mjs +12 -5
- package/src/browser.mjs +2 -2
- package/src/cli-main.mjs +20 -6
- package/src/crates-api.mjs +9 -2
- package/src/discovery.mjs +6 -1
- package/src/github.mjs +34 -1
- package/src/migration-cleanup.mjs +31 -9
- package/src/organization-scan.mjs +37 -11
- package/src/organization-setup.mjs +7 -5
- package/src/plan.mjs +12 -4
- package/src/publishers.mjs +91 -1
- package/src/publishing-state.mjs +37 -0
- package/src/registry-state.mjs +60 -23
- package/src/request-policy.mjs +27 -0
- package/src/setup.mjs +19 -3
- package/src/source-code.mjs +2 -0
- package/src/tokens.mjs +4 -2
- package/src/webdriver-automation.mjs +3 -2
- package/src/workflow-fallback.mjs +125 -0
- package/src/workflow-migration.mjs +135 -76
- package/src/workflow-proposal.mjs +36 -7
package/README.md
CHANGED
|
@@ -70,3 +70,10 @@ local `--offline` commands keep local scope. Both CLIs' GitHub dependency needs
|
|
|
70
70
|
Node.js 22.12 or newer and npm; JavaScript checks the runtime before loading it.
|
|
71
71
|
|
|
72
72
|
See the [bootstrap, wrapper and credential guide](../docs/registry-setup.md) for branch/PR setup, dual npm names, registry policies and integration limits.
|
|
73
|
+
|
|
74
|
+
Migration tries trusted publishing first and retains token fallback and expiry
|
|
75
|
+
metadata by default. `--delete-token-secrets` explicitly opts into cleanup after
|
|
76
|
+
OIDC publication and verification that no workflow still uses the credentials.
|
|
77
|
+
Saved crates.io API sessions run headlessly; sign-in opens a visible window only
|
|
78
|
+
when user action is needed. Organization migration skips and reports scan errors
|
|
79
|
+
while continuing for clean repositories.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "package-registry-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Inspect repositories and guide secure package-registry setup",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"audit": "node scripts/npm-audit.mjs"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/
|
|
43
|
+
"@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/293e9a8ab9d6d91a08e95f4e09c2a0dcad9bc809",
|
|
44
44
|
"browser-commander": "^0.34.0",
|
|
45
|
-
"command-stream": "^2.
|
|
45
|
+
"command-stream": "^2.6.0",
|
|
46
46
|
"playwright": "^1.63.0",
|
|
47
47
|
"selenium-webdriver": "^4.50.0"
|
|
48
48
|
},
|
package/src/automation.mjs
CHANGED
|
@@ -29,14 +29,20 @@ export function relayExtensionDirectory() {
|
|
|
29
29
|
* the `domains` import scope only the cookies of `domains`, the registry's
|
|
30
30
|
* sign-in domains, are imported.
|
|
31
31
|
*/
|
|
32
|
-
export function launchOptions(
|
|
32
|
+
export function launchOptions(
|
|
33
|
+
browser,
|
|
34
|
+
profile,
|
|
35
|
+
verbose = false,
|
|
36
|
+
domains = [],
|
|
37
|
+
headless = false,
|
|
38
|
+
) {
|
|
33
39
|
const options = {
|
|
34
40
|
engine:
|
|
35
41
|
findBrowserSource(browser.channel)?.controlProtocol === "cdp"
|
|
36
42
|
? "playwright"
|
|
37
43
|
: "selenium",
|
|
38
44
|
channel: browser.channel,
|
|
39
|
-
headless
|
|
45
|
+
headless,
|
|
40
46
|
verbose,
|
|
41
47
|
};
|
|
42
48
|
if (browser.executable) {
|
|
@@ -82,7 +88,7 @@ export function extensionInstructions(directory) {
|
|
|
82
88
|
* `evaluate`, `close`, and, for a launched profile, `clearCookies(domains)`.
|
|
83
89
|
*/
|
|
84
90
|
export async function connectAutomation(
|
|
85
|
-
{ browser, profile, verbose = false, domains = [] },
|
|
91
|
+
{ browser, profile, verbose = false, domains = [], headless = false },
|
|
86
92
|
dependencies = {},
|
|
87
93
|
) {
|
|
88
94
|
if (browser.attach?.mode === "extension") {
|
|
@@ -90,7 +96,7 @@ export async function connectAutomation(
|
|
|
90
96
|
}
|
|
91
97
|
if (findBrowserSource(browser.channel)?.controlProtocol === "bidi") {
|
|
92
98
|
return connectWebDriverAutomation(
|
|
93
|
-
{ browser, profile, verbose, domains },
|
|
99
|
+
{ browser, profile, verbose, domains, headless },
|
|
94
100
|
dependencies,
|
|
95
101
|
);
|
|
96
102
|
}
|
|
@@ -99,8 +105,9 @@ export async function connectAutomation(
|
|
|
99
105
|
}
|
|
100
106
|
const connection = await (
|
|
101
107
|
dependencies.launchRealBrowser ?? launchRealBrowser
|
|
102
|
-
)(launchOptions(browser, profile, verbose, domains));
|
|
108
|
+
)(launchOptions(browser, profile, verbose, domains, headless));
|
|
103
109
|
return {
|
|
110
|
+
headless,
|
|
104
111
|
goto: (url) => connection.page.goto(url),
|
|
105
112
|
evaluate: (script) => connection.page.evaluate(script),
|
|
106
113
|
close: () => connection.close(),
|
package/src/browser.mjs
CHANGED
|
@@ -42,10 +42,10 @@ export function openerSucceeded(file, code) {
|
|
|
42
42
|
* Starts an opener through command-stream without a shell and settles once it
|
|
43
43
|
* exits, or after `graceMs` while it keeps running in the background.
|
|
44
44
|
*/
|
|
45
|
-
export function detachedRunner(graceMs = OPENER_GRACE_MS) {
|
|
45
|
+
export function detachedRunner(graceMs = OPENER_GRACE_MS, spawnChild = spawn) {
|
|
46
46
|
return (file, args) =>
|
|
47
47
|
new Promise((resolve, reject) => {
|
|
48
|
-
const child =
|
|
48
|
+
const child = spawnChild(file, args, {
|
|
49
49
|
detached: process.platform !== "win32",
|
|
50
50
|
stdio: "ignore",
|
|
51
51
|
shell: false,
|
package/src/cli-main.mjs
CHANGED
|
@@ -36,7 +36,8 @@ Global options:
|
|
|
36
36
|
--org <org> | --user <login> Scan repositories through gh-manager
|
|
37
37
|
--secret-name <template> Secret name; supports {REGISTRY}, {REPO}, {ORG}
|
|
38
38
|
--rotate-token <secret> Explicitly replace a legacy crates.io token
|
|
39
|
-
--migrate Convert
|
|
39
|
+
--migrate Convert jobs to OIDC with token fallback, then verify releases
|
|
40
|
+
--delete-token-secrets Opt in to verified unused token and expiry cleanup
|
|
40
41
|
--token-expiry-days <N> Read browser token inventory in account scans
|
|
41
42
|
--format <text|json> Output format (default: text)
|
|
42
43
|
--offline Do not look up packages on registries
|
|
@@ -118,6 +119,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
118
119
|
"secret-name": { type: "string" },
|
|
119
120
|
"rotate-token": { type: "string" },
|
|
120
121
|
migrate: { type: "boolean", default: false },
|
|
122
|
+
"delete-token-secrets": { type: "boolean", default: false },
|
|
121
123
|
"token-expiry-days": { type: "string" },
|
|
122
124
|
format: { type: "string", default: "text" },
|
|
123
125
|
verbose: { type: "boolean", default: false },
|
|
@@ -273,6 +275,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
273
275
|
secretName: values["secret-name"],
|
|
274
276
|
rotateToken,
|
|
275
277
|
migrate: values.migrate,
|
|
278
|
+
deleteTokenSecrets: values["delete-token-secrets"],
|
|
276
279
|
tokenExpiryDays,
|
|
277
280
|
package: values.package,
|
|
278
281
|
browser: values.browser,
|
|
@@ -288,6 +291,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
288
291
|
publisherEnvironment: values.environment,
|
|
289
292
|
addPublishJob: values["add-publish-job"],
|
|
290
293
|
migrate: values.migrate,
|
|
294
|
+
deleteTokenSecrets: values["delete-token-secrets"],
|
|
291
295
|
manual: values.manual,
|
|
292
296
|
verifyRelease: values["verify-release"],
|
|
293
297
|
},
|
|
@@ -331,6 +335,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
331
335
|
const planOptions = {
|
|
332
336
|
rotateToken,
|
|
333
337
|
migrate: values.migrate,
|
|
338
|
+
deleteTokenSecrets: values["delete-token-secrets"],
|
|
334
339
|
ref: values.ref,
|
|
335
340
|
verifyRelease: values["verify-release"],
|
|
336
341
|
manual: values.manual,
|
|
@@ -375,9 +380,10 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
375
380
|
}
|
|
376
381
|
|
|
377
382
|
const plans = buildPlans(inspection, registries, planOptions);
|
|
378
|
-
const selected =
|
|
379
|
-
|
|
380
|
-
|
|
383
|
+
const selected =
|
|
384
|
+
values.all || (values.migrate && !values.package)
|
|
385
|
+
? plans.filter((plan) => plan.package.publishable)
|
|
386
|
+
: [selectPlan(plans, values.package)];
|
|
381
387
|
outputPlans(selected, values.format);
|
|
382
388
|
await executePlans(
|
|
383
389
|
selected,
|
|
@@ -394,6 +400,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
394
400
|
secretName: values["secret-name"],
|
|
395
401
|
rotateToken,
|
|
396
402
|
migrate: values.migrate,
|
|
403
|
+
deleteTokenSecrets: values["delete-token-secrets"],
|
|
397
404
|
quietBrowser: selected.some((plan) =>
|
|
398
405
|
plan.steps.some((step) =>
|
|
399
406
|
["manage-registry-token", "rotate-crates-token"].includes(step.id),
|
|
@@ -408,7 +415,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
408
415
|
verbose: values.verbose,
|
|
409
416
|
verifyRelease: values["verify-release"],
|
|
410
417
|
},
|
|
411
|
-
values.all,
|
|
418
|
+
values.all || values.migrate,
|
|
412
419
|
);
|
|
413
420
|
}
|
|
414
421
|
|
|
@@ -520,7 +527,14 @@ function outputInspection(inspection, format) {
|
|
|
520
527
|
packageInfo.exists_on_registry ? "published" : "not published yet",
|
|
521
528
|
);
|
|
522
529
|
}
|
|
523
|
-
if (packageInfo.
|
|
530
|
+
if (packageInfo.registry_http_status) {
|
|
531
|
+
details.push(
|
|
532
|
+
`registry state unknown (HTTP ${packageInfo.registry_http_status})`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
if (packageInfo.publishing_policy === "oidc-with-token-fallback") {
|
|
536
|
+
details.push("trusted publishing + token fallback");
|
|
537
|
+
} else if (packageInfo.trusted_publishing) {
|
|
524
538
|
details.push("trusted publishing");
|
|
525
539
|
}
|
|
526
540
|
process.stdout.write(
|
package/src/crates-api.mjs
CHANGED
|
@@ -350,11 +350,15 @@ export async function runCratesApiStep(session, step) {
|
|
|
350
350
|
}
|
|
351
351
|
const crate = plan.package.name;
|
|
352
352
|
const domains = signInDomains(plan.registry);
|
|
353
|
-
let page = await session.automatedPage();
|
|
353
|
+
let page = await session.automatedPage(undefined, false, true);
|
|
354
354
|
switch (step.id) {
|
|
355
355
|
case "crates-sign-in": {
|
|
356
356
|
await page.goto(CRATES_IO_URL);
|
|
357
|
-
|
|
357
|
+
const saved = await readSession(page);
|
|
358
|
+
if (!saved.signedIn || !saved.emailVerified) {
|
|
359
|
+
page = await session.visiblePage();
|
|
360
|
+
}
|
|
361
|
+
if (!saved.signedIn) {
|
|
358
362
|
// Whether imported or signed in by hand, the sign-in is this run's.
|
|
359
363
|
conditions.add("crates-signed-in");
|
|
360
364
|
if (await offerSignInImport(session, domains)) {
|
|
@@ -362,6 +366,9 @@ export async function runCratesApiStep(session, step) {
|
|
|
362
366
|
}
|
|
363
367
|
}
|
|
364
368
|
await signIn(page, options);
|
|
369
|
+
console.log(
|
|
370
|
+
"Using your crates.io session to register trusted publishers; no action needed while API requests run.",
|
|
371
|
+
);
|
|
365
372
|
return;
|
|
366
373
|
}
|
|
367
374
|
case "first-publish": {
|
package/src/discovery.mjs
CHANGED
|
@@ -246,6 +246,9 @@ function withPublisher(item, workflows, publishers) {
|
|
|
246
246
|
return item;
|
|
247
247
|
}
|
|
248
248
|
const result = { ...item };
|
|
249
|
+
if (publisher.policy) {
|
|
250
|
+
result.publishing_policy = publisher.policy;
|
|
251
|
+
}
|
|
249
252
|
if (publisher.workflow) {
|
|
250
253
|
result.workflow = publisher.workflow;
|
|
251
254
|
}
|
|
@@ -266,7 +269,9 @@ function withPublisher(item, workflows, publishers) {
|
|
|
266
269
|
`no workflow publishes ${item.name} to ${item.registry}; CI releases will not reach it`,
|
|
267
270
|
]
|
|
268
271
|
: []),
|
|
269
|
-
...secrets
|
|
272
|
+
...secrets
|
|
273
|
+
.filter(() => publisher.policy !== "oidc-with-token-fallback")
|
|
274
|
+
.map((secret) => tokenSecretWarning(item.registry, secret)),
|
|
270
275
|
];
|
|
271
276
|
if (warnings.length > 0) {
|
|
272
277
|
result.warnings = [...(item.warnings ?? []), ...warnings];
|
package/src/github.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { delay, retryDelay } from "./request-policy.mjs";
|
|
1
2
|
import {
|
|
2
3
|
createRestClient,
|
|
3
4
|
resolveToken,
|
|
@@ -15,7 +16,9 @@ export function githubServices(options = {}) {
|
|
|
15
16
|
"sign in to GitHub with gh auth login before scanning or setting secrets",
|
|
16
17
|
);
|
|
17
18
|
}
|
|
18
|
-
const rest =
|
|
19
|
+
const rest =
|
|
20
|
+
options.rest ??
|
|
21
|
+
createRestClient({ token, fetch: retryingGithubFetch(options) });
|
|
19
22
|
const log = {
|
|
20
23
|
debug: (message) => {
|
|
21
24
|
if (options.verbose) {
|
|
@@ -41,3 +44,33 @@ export function githubServices(options = {}) {
|
|
|
41
44
|
},
|
|
42
45
|
};
|
|
43
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Retry rate-limited reads before gh-manager consumes response headers. */
|
|
49
|
+
export function retryingGithubFetch(options = {}) {
|
|
50
|
+
return async (url, init = {}) => {
|
|
51
|
+
const fetcher = options.githubFetch ?? globalThis.fetch;
|
|
52
|
+
for (let attempt = 0; ; attempt++) {
|
|
53
|
+
const response = await fetcher(url, init);
|
|
54
|
+
if (
|
|
55
|
+
![403, 429].includes(response.status) ||
|
|
56
|
+
attempt >= 3 ||
|
|
57
|
+
!["GET", "HEAD"].includes(init.method ?? "GET")
|
|
58
|
+
) {
|
|
59
|
+
return response;
|
|
60
|
+
}
|
|
61
|
+
const reset =
|
|
62
|
+
Number(response.headers?.get("x-ratelimit-reset")) * 1000 - Date.now();
|
|
63
|
+
const wait = Math.max(
|
|
64
|
+
retryDelay(response.headers?.get("retry-after"), attempt),
|
|
65
|
+
Number.isFinite(reset) ? reset : 0,
|
|
66
|
+
);
|
|
67
|
+
if (options.verbose) {
|
|
68
|
+
console.error(
|
|
69
|
+
`GitHub read rate limited (HTTP ${response.status}); retry ${attempt + 1} in ${wait}ms`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
await response.body?.cancel();
|
|
73
|
+
await (options.sleep ?? delay)(wait);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -36,11 +36,22 @@ export function publishedThroughOidc(job, log, registry) {
|
|
|
36
36
|
) && /Published\s+\S+\s+v\d/.test(log)
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
|
+
if (steps.some((step) => /token fallback/i.test(step.name))) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
39
42
|
if (registry === "npm") {
|
|
40
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
steps.some((step) => /OIDC/i.test(step.name)) &&
|
|
45
|
+
/^.*\+\s+(?:@[^/\s]+\/)?[^@\s]+@\d/m.test(log)
|
|
46
|
+
);
|
|
41
47
|
}
|
|
42
48
|
if (registry === "pypi") {
|
|
43
|
-
return
|
|
49
|
+
return (
|
|
50
|
+
steps.some(
|
|
51
|
+
(step) =>
|
|
52
|
+
/prm_oidc|OIDC/i.test(step.name) && !/fallback/.test(step.name),
|
|
53
|
+
) && /Successfully uploaded|View at:\s*https:\/\/pypi\.org/.test(log)
|
|
54
|
+
);
|
|
44
55
|
}
|
|
45
56
|
return false;
|
|
46
57
|
}
|
|
@@ -80,13 +91,6 @@ export async function cleanupMigratedSecrets(session) {
|
|
|
80
91
|
];
|
|
81
92
|
const current = await defaultWorkflows(github, slug);
|
|
82
93
|
const stillRead = names.filter((name) => readsSecret(current.files, name));
|
|
83
|
-
if (stillRead.length) {
|
|
84
|
-
session.outcome = { status: "migration-required" };
|
|
85
|
-
console.log(
|
|
86
|
-
`${slug} still reads ${stillRead.join(", ")}; merge the conversion PR before cleanup.`,
|
|
87
|
-
);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
94
|
const workflow = plan.trusted_publisher?.workflow ?? plan.package.workflow;
|
|
91
95
|
const runs = await github.runs.list(slug, {
|
|
92
96
|
workflow,
|
|
@@ -124,6 +128,24 @@ export async function cleanupMigratedSecrets(session) {
|
|
|
124
128
|
);
|
|
125
129
|
return;
|
|
126
130
|
}
|
|
131
|
+
if (!options.deleteTokenSecrets) {
|
|
132
|
+
session.outcome = {
|
|
133
|
+
status: "fallback-retained",
|
|
134
|
+
oidc_verified: true,
|
|
135
|
+
fallback_secrets: stillRead,
|
|
136
|
+
};
|
|
137
|
+
console.log(
|
|
138
|
+
`${slug}: OIDC publication verified; token secrets (${stillRead.join(", ") || "none referenced"}) and expiry metadata retained for fallback. Use --delete-token-secrets only after removing every consumer.`,
|
|
139
|
+
);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (stillRead.length) {
|
|
143
|
+
session.outcome = { status: "migration-required" };
|
|
144
|
+
console.log(
|
|
145
|
+
`${slug} still reads ${stillRead.join(", ")}; merge the conversion PR before cleanup.`,
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
127
149
|
const deletions = [];
|
|
128
150
|
for (const name of names) {
|
|
129
151
|
const manager = github.secrets({ repo: slug });
|
|
@@ -52,6 +52,13 @@ export function packageFindings(inspection) {
|
|
|
52
52
|
manifest: item.manifest,
|
|
53
53
|
};
|
|
54
54
|
const findings = [];
|
|
55
|
+
if (item.exists_on_registry === undefined && item.registry_http_status) {
|
|
56
|
+
findings.push({
|
|
57
|
+
...common,
|
|
58
|
+
type: "registry-state-unknown",
|
|
59
|
+
evidence: { http_status: item.registry_http_status },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
55
62
|
if (item.exists_on_registry === false) {
|
|
56
63
|
findings.push({ ...common, type: "unpublished" });
|
|
57
64
|
}
|
|
@@ -267,14 +274,24 @@ async function releaseHealth(github, item, findings, onError) {
|
|
|
267
274
|
}
|
|
268
275
|
const runs = [];
|
|
269
276
|
for (const event of ["push", "workflow_dispatch"]) {
|
|
270
|
-
|
|
271
|
-
|
|
277
|
+
let listed;
|
|
278
|
+
try {
|
|
279
|
+
listed = await github.runs.list(item.repository, {
|
|
272
280
|
workflow,
|
|
273
281
|
branch: item.default_branch,
|
|
274
282
|
event,
|
|
275
283
|
limit: 1,
|
|
276
|
-
})
|
|
277
|
-
)
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!/404/.test(error.message)) {
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
listed = await github.runs.list(item.repository, {
|
|
290
|
+
workflow,
|
|
291
|
+
event,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
runs.push(...listed);
|
|
278
295
|
}
|
|
279
296
|
const latest = runs
|
|
280
297
|
.filter(
|
|
@@ -304,10 +321,17 @@ async function releaseHealth(github, item, findings, onError) {
|
|
|
304
321
|
evidence,
|
|
305
322
|
};
|
|
306
323
|
findings.push(finding);
|
|
307
|
-
evidence.matches = await github.runs
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
324
|
+
evidence.matches = await github.runs
|
|
325
|
+
.logs(latest.id, {
|
|
326
|
+
repo: item.repository,
|
|
327
|
+
grep: RELEASE_FAILURE_PATTERNS,
|
|
328
|
+
})
|
|
329
|
+
.catch((error) => {
|
|
330
|
+
if (/GitHub log download (?:404|410)/.test(error.message)) {
|
|
331
|
+
return { status: "logs-expired", matches: [] };
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
});
|
|
311
335
|
} catch (error) {
|
|
312
336
|
onError({ stage: "release", workflow, message: error.message });
|
|
313
337
|
}
|
|
@@ -351,9 +375,11 @@ export function renderScan(scan) {
|
|
|
351
375
|
const status =
|
|
352
376
|
pkg.exists_on_registry === undefined
|
|
353
377
|
? "registry state unknown"
|
|
354
|
-
: pkg.
|
|
355
|
-
? "
|
|
356
|
-
:
|
|
378
|
+
: pkg.publishing_policy === "oidc-with-token-fallback"
|
|
379
|
+
? "trusted publishing + token fallback"
|
|
380
|
+
: pkg.trusted_publishing === true
|
|
381
|
+
? "published with trusted publishing"
|
|
382
|
+
: "published; trusted publishing unknown";
|
|
357
383
|
rows.push(`${repo.repository}\t${pkg.registry}\t${pkg.name}\t${status}`);
|
|
358
384
|
}
|
|
359
385
|
}
|
|
@@ -144,8 +144,13 @@ export async function accountCommand(command, options, outputPlans) {
|
|
|
144
144
|
if (errors.length) {
|
|
145
145
|
console.error(renderScan({ repositories: errors, findings: [] }));
|
|
146
146
|
}
|
|
147
|
-
if (options.migrate
|
|
148
|
-
|
|
147
|
+
if (options.migrate) {
|
|
148
|
+
items = items.filter((item) => !item.error);
|
|
149
|
+
for (const item of errors) {
|
|
150
|
+
console.error(
|
|
151
|
+
`Skipping migration for ${item.repository}: ${item.error}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
149
154
|
}
|
|
150
155
|
const packages = items.flatMap((item) => item.inspection.packages);
|
|
151
156
|
const environment = await (options.probeEnvironment ?? probeEnvironment)({
|
|
@@ -247,9 +252,6 @@ export async function accountCommand(command, options, outputPlans) {
|
|
|
247
252
|
"some repositories could not be prepared; see scan errors",
|
|
248
253
|
);
|
|
249
254
|
}
|
|
250
|
-
if (options.migrate && scan.repositories.some((item) => item.error)) {
|
|
251
|
-
throw new Error("migration requires a complete account scan");
|
|
252
|
-
}
|
|
253
255
|
}
|
|
254
256
|
const secretRepositories = {};
|
|
255
257
|
for (const plan of plans) {
|
package/src/plan.mjs
CHANGED
|
@@ -75,7 +75,9 @@ function buildPlan(inspection, packageInfo, options) {
|
|
|
75
75
|
if (
|
|
76
76
|
(options.migrate || options.addPublishJob) &&
|
|
77
77
|
["crates-io", "npm", "pypi"].includes(packageInfo.registry) &&
|
|
78
|
-
packageInfo.
|
|
78
|
+
(packageInfo.publishing_policy
|
|
79
|
+
? packageInfo.publishing_policy !== "oidc-with-token-fallback"
|
|
80
|
+
: packageInfo.token_secrets?.length)
|
|
79
81
|
) {
|
|
80
82
|
return basePlan(inspection, packageInfo, [
|
|
81
83
|
{
|
|
@@ -84,7 +86,7 @@ function buildPlan(inspection, packageInfo, options) {
|
|
|
84
86
|
kind: "manual",
|
|
85
87
|
confirm: true,
|
|
86
88
|
description:
|
|
87
|
-
"Convert existing publishing jobs and reusable callers to OIDC, preserve the release commands and environment, and
|
|
89
|
+
"Convert existing publishing jobs and reusable callers to OIDC, preserve the release commands and environment, and retain token secrets as fallback. Review and merge the PR, then re-run setup --migrate to register trust and verify an OIDC release while retaining fallback secrets by default.",
|
|
88
90
|
},
|
|
89
91
|
]);
|
|
90
92
|
}
|
|
@@ -241,6 +243,12 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
241
243
|
: steps.filter((item) => !BOOTSTRAP_CONDITIONS.has(item.when)),
|
|
242
244
|
);
|
|
243
245
|
}
|
|
246
|
+
if (!options.deleteTokenSecrets) {
|
|
247
|
+
steps = steps.filter(
|
|
248
|
+
(step) =>
|
|
249
|
+
!["confirm-oidc-cleanup", "delete-token-secret"].includes(step.id),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
244
252
|
if (options.migrate) {
|
|
245
253
|
steps = steps.filter(
|
|
246
254
|
(step) =>
|
|
@@ -252,11 +260,11 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
252
260
|
);
|
|
253
261
|
steps.push({
|
|
254
262
|
id: "cleanup-migrated-secrets",
|
|
255
|
-
title: "Verify OIDC publication and
|
|
263
|
+
title: "Verify OIDC publication and report fallback secrets",
|
|
256
264
|
kind: "api",
|
|
257
265
|
confirm: true,
|
|
258
266
|
description:
|
|
259
|
-
"Read the default-branch workflows and successful publishing steps at its current commit.
|
|
267
|
+
"Read the default-branch workflows and successful publishing steps at its current commit. Retain token secrets and expiry metadata for fallback by default. With --delete-token-secrets, delete repository secrets only after verification and proof of non-use; delete organization secrets only after a complete fresh scan proves no remaining consumer.",
|
|
260
268
|
});
|
|
261
269
|
}
|
|
262
270
|
if (options.ref) {
|
package/src/publishers.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { publishingPolicy } from "./publishing-state.mjs";
|
|
1
2
|
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { stripComments, commandPosition } from "./source-code.mjs";
|
|
@@ -146,6 +147,76 @@ export function parseWorkflow(contents) {
|
|
|
146
147
|
* the trusted publisher names the workflow CI actually publishes from.
|
|
147
148
|
*/
|
|
148
149
|
export async function detectPublisher(root, workflows, registry) {
|
|
150
|
+
const result = await detectPublisherDetails(root, workflows, registry);
|
|
151
|
+
if (
|
|
152
|
+
["crates-io", "npm", "pypi"].includes(registry) &&
|
|
153
|
+
(result.workflow || result.candidates.length)
|
|
154
|
+
) {
|
|
155
|
+
result.policy = await publishingPolicies(root, workflows, registry);
|
|
156
|
+
if (result.policy !== "oidc-with-token-fallback") {
|
|
157
|
+
result.warnings.push(
|
|
158
|
+
`publishing authentication is ${result.policy}; migrate to OIDC with token fallback`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function publishingPolicies(root, workflows, registry) {
|
|
166
|
+
const parsed = new Map(
|
|
167
|
+
workflows.map((workflow) => [
|
|
168
|
+
workflow.name,
|
|
169
|
+
parseWorkflow(workflow.contents),
|
|
170
|
+
]),
|
|
171
|
+
);
|
|
172
|
+
const policies = [];
|
|
173
|
+
const visit = async (name, callerGrant, seen = new Set()) => {
|
|
174
|
+
if (seen.has(name) || !parsed.has(name)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const workflow = parsed.get(name);
|
|
178
|
+
const workflowGrant = grantsIdToken(
|
|
179
|
+
childBlock(workflow.header, 0, "permissions"),
|
|
180
|
+
);
|
|
181
|
+
for (const job of workflow.jobs) {
|
|
182
|
+
const own =
|
|
183
|
+
grantsIdToken(
|
|
184
|
+
childBlock(job.lines, childIndent(job.lines), "permissions"),
|
|
185
|
+
) ?? workflowGrant;
|
|
186
|
+
const grant =
|
|
187
|
+
callerGrant === undefined
|
|
188
|
+
? (own ?? false)
|
|
189
|
+
: callerGrant && (own ?? true);
|
|
190
|
+
const called = LOCAL_WORKFLOW.exec(
|
|
191
|
+
job.lines.find((line) => LOCAL_WORKFLOW.test(line)) ?? "",
|
|
192
|
+
)?.[1];
|
|
193
|
+
if (called) {
|
|
194
|
+
await visit(called, grant, new Set([...seen, name]));
|
|
195
|
+
} else if (await jobPublishes(root, workflow, job, registry)) {
|
|
196
|
+
policies.push(
|
|
197
|
+
publishingPolicy(
|
|
198
|
+
[[...workflow.header, ...job.lines].join("\n")],
|
|
199
|
+
registry,
|
|
200
|
+
grant,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
for (const workflow of workflows) {
|
|
207
|
+
if (!onlyCalled(parsed.get(workflow.name).header)) {
|
|
208
|
+
await visit(workflow.name);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const unique = [...new Set(policies)];
|
|
212
|
+
return unique.length === 1
|
|
213
|
+
? unique[0]
|
|
214
|
+
: unique.length
|
|
215
|
+
? "mixed-authentication"
|
|
216
|
+
: "authentication-unverified";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function detectPublisherDetails(root, workflows, registry) {
|
|
149
220
|
const parsedWorkflows = new Map(
|
|
150
221
|
workflows.map((workflow) => [
|
|
151
222
|
workflow.name,
|
|
@@ -289,7 +360,7 @@ export function tokenSecrets(workflows, registry) {
|
|
|
289
360
|
/** Explains how to replace a long-lived token secret with trusted publishing. */
|
|
290
361
|
export function tokenSecretWarning(registry, { workflow, secret }) {
|
|
291
362
|
return TRUSTED_REGISTRIES.includes(registry)
|
|
292
|
-
? `${workflow} reads secrets.${secret}; publish with ${TRUSTED_PUBLISHING_HINTS.get(registry) ?? `${registry} trusted publishing (id-token: write)`} and
|
|
363
|
+
? `${workflow} reads secrets.${secret}; publish with ${TRUSTED_PUBLISHING_HINTS.get(registry) ?? `${registry} trusted publishing (id-token: write)`} and retain a rotated token as fallback`
|
|
293
364
|
: `${workflow} reads secrets.${secret}; ensure a scoped expiring publishing token through gh-manager`;
|
|
294
365
|
}
|
|
295
366
|
|
|
@@ -459,6 +530,25 @@ function scriptPublishes(lines, registry) {
|
|
|
459
530
|
) {
|
|
460
531
|
return true;
|
|
461
532
|
}
|
|
533
|
+
const commands = new Set();
|
|
534
|
+
for (const line of lines) {
|
|
535
|
+
const declaration = /\b(?:const|let|var)\s+(\w+)\s*=/.exec(line);
|
|
536
|
+
if (
|
|
537
|
+
declaration &&
|
|
538
|
+
new RegExp(pattern.source).test(line.slice(declaration.index))
|
|
539
|
+
) {
|
|
540
|
+
commands.add(declaration[1]);
|
|
541
|
+
}
|
|
542
|
+
if (
|
|
543
|
+
[...commands].some((name) =>
|
|
544
|
+
new RegExp(
|
|
545
|
+
`(?:exec(?:Sync)?|spawn(?:Sync)?|run|system)\\s*\\(\\s*${name}\\b`,
|
|
546
|
+
).test(line),
|
|
547
|
+
)
|
|
548
|
+
) {
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
462
552
|
const patterns = SCRIPT_PATTERNS.get(registry);
|
|
463
553
|
if (!patterns) {
|
|
464
554
|
return false;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Describe release authentication, independently from the last registry publication. */
|
|
2
|
+
export function publishingPolicy(contents, registry, grantOverride) {
|
|
3
|
+
const text = contents.join("\n");
|
|
4
|
+
const token =
|
|
5
|
+
/secrets\.(?:CARGO_(?:REGISTRY_)?TOKEN|NPM_(?:AUTH_)?TOKEN|NODE_AUTH_TOKEN|PYPI_(?:API_)?TOKEN)/.test(
|
|
6
|
+
text,
|
|
7
|
+
);
|
|
8
|
+
const grant =
|
|
9
|
+
grantOverride ?? /id-token:\s*write|permissions:\s*write-all/.test(text);
|
|
10
|
+
const oidc =
|
|
11
|
+
grant &&
|
|
12
|
+
(registry === "crates-io"
|
|
13
|
+
? text
|
|
14
|
+
.split(/(?=^[ \t]*- )/m)
|
|
15
|
+
.some(
|
|
16
|
+
(step) =>
|
|
17
|
+
/crates-io-auth-action@/.test(step) &&
|
|
18
|
+
!/\bif:[\s\S]*\bvars\./.test(step),
|
|
19
|
+
)
|
|
20
|
+
: registry === "pypi"
|
|
21
|
+
? /pypa\/gh-action-pypi-publish@/.test(text)
|
|
22
|
+
: /npm publish|npm run|node .*(?:publish|release)/.test(text));
|
|
23
|
+
const fallback =
|
|
24
|
+
registry === "crates-io"
|
|
25
|
+
? /outputs\.token\s*\|\|\s*secrets\./.test(text) &&
|
|
26
|
+
/continue-on-error:\s*true/.test(text)
|
|
27
|
+
: /outcome == 'failure'/.test(text) &&
|
|
28
|
+
/continue-on-error:\s*true/.test(text);
|
|
29
|
+
if (oidc && token && fallback) {
|
|
30
|
+
return "oidc-with-token-fallback";
|
|
31
|
+
}
|
|
32
|
+
return oidc
|
|
33
|
+
? "oidc-only"
|
|
34
|
+
: token
|
|
35
|
+
? "token-only"
|
|
36
|
+
: "authentication-unverified";
|
|
37
|
+
}
|