Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions scripts/release-finalize-stable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
const MAX_HISTORICAL_COMMITS = 8
const MAX_NPM_READS = 6
const MAX_NPM_CONFIG_BYTES = 64 * 1024
const MAX_NPM_CONFIG_VALUE_BYTES = 4096
const MAX_TRACKED_NPM_CONFIGS = 64
const MAX_OPERATIONAL_ERROR_CHARS = 2048
const MAX_OPERATIONAL_DIAGNOSTIC_BYTES = 320
Expand Down Expand Up @@ -68,7 +69,7 @@
export function operationalFailureDiagnostic(error) {
const message = typeof error?.message === "string" ? error.message.slice(0, MAX_OPERATIONAL_ERROR_CHARS) : ""
const sanitized = message
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, " ")

Check warning on line 72 in scripts/release-finalize-stable.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

eslint(no-control-regex)

Unexpected control character
.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/giu, "[redacted URL]")
.replace(/\b(?:bearer|basic)\s+[^\s]+/giu, "[redacted credential]")
.replace(/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu, "[redacted JWT]")
Expand All @@ -77,7 +78,7 @@
/((?:(?:auth|access|refresh|id)[_-]?token|_authToken|password|passwd|secret|credential|api[_-]?key)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu,
"$1[redacted]",
)
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")

Check warning on line 81 in scripts/release-finalize-stable.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

eslint(no-control-regex)

Unexpected control characters
.replace(/\s+/gu, " ")
.trim()
return boundedUtf8(sanitized || "operation failed without a safe message", MAX_OPERATIONAL_DIAGNOSTIC_BYTES)
Expand Down Expand Up @@ -121,7 +122,13 @@
})
child.on("close", (code, signal) => {
clearTimeout(timer)
const result = { code, signal, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") }
const result = {
code,
signal,
stdout: stdout.toString("utf8"),
stderr: stderr.toString("utf8"),
stdoutBytes: stdout,
}
if (excessive) reject(new Error(`${file} output exceeded bound`))
else if (signal) reject(new Error(`${file} timed out or terminated (${signal})`))
else if (!ok.includes(code)) {
Expand All @@ -146,7 +153,7 @@
value.length <= 512 &&
!isAbsolute(value) &&
!value.includes("\\") &&
!/[\u0000-\u001f\u007f]/.test(value) &&

Check warning on line 156 in scripts/release-finalize-stable.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

eslint(no-control-regex)

Unexpected control characters
!value.includes("//") &&
value.split("/").every((part) => part && part !== "." && part !== "..")
)
Expand Down Expand Up @@ -731,12 +738,36 @@
function npmConfigFailure() {
fail("npm auth configuration could not be safely verified at the publication boundary")
}
function parseNpmConfigValue(output) {
if (!Buffer.isBuffer(output) || output.length === 0 || output.length > MAX_NPM_CONFIG_VALUE_BYTES + 2) {
npmConfigFailure()
}

let value
try {
value = new TextDecoder("utf-8", { fatal: true }).decode(output)
} catch {
npmConfigFailure()
}
if (value.endsWith("\r\n")) value = value.slice(0, -2)
else if (value.endsWith("\n")) value = value.slice(0, -1)

if (
value.length === 0 ||
Buffer.byteLength(value) > MAX_NPM_CONFIG_VALUE_BYTES ||
value !== value.trim() ||
/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\ufffd]/u.test(value)
) {
npmConfigFailure()
}
return value
}
function stableFileIdentity(left, right) {
return ["dev", "ino", "mode", "nlink", "size", "mtimeNs", "ctimeNs"].every((key) => left[key] === right[key])
}
function authBearingNpmConfig(text) {
if (
/[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]/u.test(text) ||

Check warning on line 770 in scripts/release-finalize-stable.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

eslint(no-control-regex)

Unexpected control characters
text.replaceAll("\r\n", "").includes("\r")
) {
return true
Expand Down Expand Up @@ -871,11 +902,11 @@
async function configuredNpmPath(key) {
let configured
try {
configured = await run("npm", ["config", "get", key, "--json"])
configured = await run("npm", ["config", "get", key])
} catch {
npmConfigFailure()
}
const path = parseJson(configured.stdout, `npm ${key} configuration`)
const path = parseNpmConfigValue(configured.stdoutBytes)
if (
typeof path !== "string" ||
path.length === 0 ||
Expand All @@ -900,11 +931,11 @@

let registry
try {
registry = await run("npm", ["config", "get", "registry", "--json"])
registry = await run("npm", ["config", "get", "registry"])
} catch {
npmConfigFailure()
}
if (parseJson(registry.stdout, "effective npm registry") !== NPM_REGISTRY) {
if (parseNpmConfigValue(registry.stdoutBytes) !== NPM_REGISTRY) {
fail("effective npm registry is not the trusted npmjs registry at the publication boundary")
}

Expand Down
55 changes: 49 additions & 6 deletions scripts/release-finalize-stable.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@
function finish(code=0){save();process.exit(code)}
function take(value,key,fallback){const queue=value[key];if(queue&&queue.length){const next=queue.shift();if(queue.length===0)value[key.replace(/Queue$/,"")]=next;return next}return fallback}
function emit(value){if(value&&typeof value==="object"&&value.exit){if(value.stdout)process.stdout.write(value.stdout);if(value.stderr)process.stderr.write(value.stderr);finish(value.exit)}if(value&&typeof value==="object"&&Object.hasOwn(value,"raw"))out(value.raw);else out(JSON.stringify(value)+"\n");finish()}
function emitConfig(value){if(value&&typeof value==="object"&&Object.hasOwn(value,"bytes")){process.stdout.write(Buffer.from(value.bytes));finish()}if(value&&typeof value==="object"&&Object.hasOwn(value,"raw"))out(value.raw);else out(String(value)+"\n");finish()}
function materialize(name){const value=s.npm[name],pkg=s.packages[name];if(!value.versions.includes(pkg.version))value.versions.push(pkg.version);value.latest=pkg.version;value.dist=pkg.dist}
if(cmd==="git"){
if(a[0]==="fetch"||a[0]==="config")finish()
Expand Down Expand Up @@ -237,9 +238,9 @@
finish(127)
}
if(cmd==="npm"){
if(a[0]==="config"&&a[1]==="get"&&a[2]==="userconfig")emit(s.userConfigPath)
if(a[0]==="config"&&a[1]==="get"&&a[2]==="globalconfig")emit(s.globalConfigPath)
if(a[0]==="config"&&a[1]==="get"&&a[2]==="registry")emit(s.registry)
if(a[0]==="config"&&a[1]==="get"&&a[2]==="userconfig")emitConfig(s.userConfigPath)
if(a[0]==="config"&&a[1]==="get"&&a[2]==="globalconfig")emitConfig(s.globalConfigPath)
if(a[0]==="config"&&a[1]==="get"&&a[2]==="registry")emitConfig(s.registry)
if(a[0]==="view"){
const spec=a[1],field=a[2]
if(field==="dist"){
Expand Down Expand Up @@ -795,7 +796,7 @@
assert.ok(result.stderr.length < 5000, `diagnostic length: ${result.stderr.length}`)
for (const secret of secretValues)
assert.doesNotMatch(result.stderr, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
assert.doesNotMatch(result.stderr, /\u001b|\x1b|\[31m/)

Check warning on line 799 in scripts/release-finalize-stable.test.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

eslint(no-control-regex)

Unexpected control characters
assert.match(result.stderr, /trusted publishing authentication failed/i)
assert.match(result.stderr, /E401/)
const publishIndex = final.log.findIndex(([command, operation]) => command === "npm" && operation === "publish")
Expand Down Expand Up @@ -830,9 +831,9 @@
const final = load(world.stateFile)
assert.equal(result.status, 0, result.stderr)
for (const expectedCall of [
["npm", "config", "get", "registry", "--json"],
["npm", "config", "get", "userconfig", "--json"],
["npm", "config", "get", "globalconfig", "--json"],
["npm", "config", "get", "registry"],
["npm", "config", "get", "userconfig"],
["npm", "config", "get", "globalconfig"],
]) {
assert.ok(
final.log.some((call) => isDeepStrictEqual(call, expectedCall)),
Expand Down Expand Up @@ -876,6 +877,48 @@
})
})

await test("npm plain-text config values reject malformed, multiline, control, and ambiguous output", async (t) => {
for (const [name, setup] of [
["empty registry", (state) => (state.registry = { raw: "" })],
["multiline registry", (state) => (state.registry = { raw: `${npmRegistry}\nhttps://registry.example.test/\n` })],
["control-bearing registry", (state) => (state.registry = { raw: `${npmRegistry}\u0000\n` })],
["invalid UTF-8 registry", (state) => (state.registry = { bytes: [0xc3, 0x28, 0x0a] })],
["leading whitespace registry", (state) => (state.registry = { raw: ` ${npmRegistry}\n` })],
["trailing whitespace registry", (state) => (state.registry = { raw: `${npmRegistry} \n` })],
["oversized registry", (state) => (state.registry = { raw: `${"a".repeat(4097)}\n` })],
[
"multiline user config path",
(state, world) => {
const path = join(world.cwd, "user.npmrc")
state.userConfigPath = { raw: `${path}\n${path}\n` }
},
],
[
"control-bearing global config path",
(state, world) => {
state.globalConfigPath = { raw: `${join(world.cwd, "global.npmrc")}\u0007\n` }
},
],
]) {
await t.test(name, async () => {
const world = await makeWorld({ npmMode: "absent" })
try {
const state = load(world.stateFile)
setup(state, world)
save(world.stateFile, state)

const result = await run(world)
const final = load(world.stateFile)
assert.notEqual(result.status, 0)
assert.match(result.stderr, /npm auth configuration|npm registry|publication boundary/i)
assert.deepEqual(mutationCalls(final), [])
} finally {
await discardWorld(world)
}
})
}
})

await test("static credentials and auth-bearing tracked, user, or global npm configuration are rejected and redacted", async (t) => {
for (const [name, setup, environment, secret] of [
["NODE_AUTH_TOKEN", async () => {}, { NODE_AUTH_TOKEN: "static-secret" }, "static-secret"],
Expand Down Expand Up @@ -1198,7 +1241,7 @@
]) {
assert.equal(diagnostic.includes(secret), false)
}
assert.doesNotMatch(diagnostic, /\u001b|\x1b|\[31m/)

Check warning on line 1244 in scripts/release-finalize-stable.test.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

eslint(no-control-regex)

Unexpected control characters
})

await test("failed tag pushes and Release creation retain only safe bounded causes after postverification", async (t) => {
Expand All @@ -1224,7 +1267,7 @@
assert.match(result.stderr, /remote tag postverification failed.*operation cause: git failed \(37\)/is)
assert.ok(result.stderr.length < 2_000, `diagnostic length: ${result.stderr.length}`)
for (const secret of secretValues) assert.equal(result.stderr.includes(secret), false)
assert.doesNotMatch(result.stderr, /\u001b|\x1b|\[31m/)

Check warning on line 1270 in scripts/release-finalize-stable.test.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

eslint(no-control-regex)

Unexpected control characters
})

await scenario(t, { npmMode: "absent", artifacts: "absent" }, async (world) => {
Expand All @@ -1242,7 +1285,7 @@
)
assert.ok(result.stderr.length < 2_000, `diagnostic length: ${result.stderr.length}`)
for (const secret of secretValues) assert.equal(result.stderr.includes(secret), false)
assert.doesNotMatch(result.stderr, /\u001b|\x1b|\[31m/)

Check warning on line 1288 in scripts/release-finalize-stable.test.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

eslint(no-control-regex)

Unexpected control characters
})
})

Expand Down
Loading