From 240a4e1cde11bebe35b12ef4c55ecb1ad6e5c28f Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 20 May 2026 00:04:18 +0000 Subject: [PATCH 01/13] chore(deps): pin vulnerable transitive ranges --- pnpm-lock.yaml | 44 ++++++++++++++++---------------------------- pnpm-workspace.yaml | 3 +++ 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa4c35f..8683d82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,8 @@ catalogs: version: 24.12.2 overrides: + brace-expansion@>=5.0.0 <5.0.6: 5.0.6 + ws@>=8.0.0 <8.20.1: 8.20.1 '@ckb-ccc/ccc': workspace:* '@ckb-ccc/core': workspace:* '@ckb-ccc/udt': workspace:* @@ -395,10 +397,10 @@ importers: version: 6.16.0 isomorphic-ws: specifier: ^5.0.0 - version: 5.0.0(ws@8.20.0) + version: 5.0.0(ws@8.20.1) ws: - specifier: ^8.18.3 - version: 8.20.0 + specifier: 8.20.1 + version: 8.20.1 devDependencies: '@eslint/js': specifier: ^9.34.0 @@ -2469,8 +2471,8 @@ packages: brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} brorand@1.1.0: @@ -3108,7 +3110,7 @@ packages: isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: - ws: '*' + ws: 8.20.1 istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -4039,20 +4041,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.17.1: - resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5341,7 +5331,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5773,7 +5763,7 @@ snapshots: '@types/node': 22.7.5 aes-js: 4.0.0-beta.5 tslib: 2.7.0 - ws: 8.17.1 + ws: 8.20.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -6004,9 +5994,9 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.20.0): + isomorphic-ws@5.0.0(ws@8.20.1): dependencies: - ws: 8.20.0 + ws: 8.20.1 istanbul-lib-coverage@3.2.2: {} @@ -6207,7 +6197,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: @@ -6935,9 +6925,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.17.1: {} - - ws@8.20.0: {} + ws@8.20.1: {} xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ef6a610..0947c31 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,9 @@ auditConfig: - CVE-2025-14505 overrides: + # Security pins for vulnerable transitive ranges not yet lifted by upstreams. + "brace-expansion@>=5.0.0 <5.0.6": 5.0.6 + "ws@>=8.0.0 <8.20.1": 8.20.1 # Keep published manifests on `catalog:` while forcing the materialized # local CCC workspace to satisfy direct stack dependencies during installs. # Update this list alongside any new direct `@ckb-ccc/*` dependency. From ce18bd091c145be50d0d30a135b6913085e62009 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 20 May 2026 00:04:25 +0000 Subject: [PATCH 02/13] feat(node-utils): harden runtime config preflight --- package.json | 2 + packages/node-utils/src/index.test.ts | 225 ++++++++++++++++- packages/node-utils/src/index.ts | 256 +++++++++++++++++++- scripts/ickb-generate-config.mjs | 332 ++++++++++++++++++++++++++ scripts/ickb-generate-config.test.mjs | 270 +++++++++++++++++++++ scripts/ickb-live-preflight.mjs | 296 +++++++++++++++++++++++ scripts/ickb-live-preflight.test.mjs | 266 +++++++++++++++++++++ 7 files changed, 1639 insertions(+), 8 deletions(-) create mode 100644 scripts/ickb-generate-config.mjs create mode 100644 scripts/ickb-generate-config.test.mjs create mode 100644 scripts/ickb-live-preflight.mjs create mode 100644 scripts/ickb-live-preflight.test.mjs diff --git a/package.json b/package.json index 2797c10..0abf726 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "forks:ccc:plan": "node scripts/forks-ccc.mjs --plan", "forks:ccc:smoke": "node scripts/forks-ccc-smoke.mjs", "forks:ccc:watch": "node scripts/forks-ccc.mjs --watch", + "live:generate-config": "node scripts/ickb-generate-config.mjs", + "live:preflight": "node scripts/ickb-live-preflight.mjs", "coworker:ask": "opencode run --pure --agent plan" }, "engines": { diff --git a/packages/node-utils/src/index.test.ts b/packages/node-utils/src/index.test.ts index ca9bea4..ff77527 100644 --- a/packages/node-utils/src/index.test.ts +++ b/packages/node-utils/src/index.test.ts @@ -1,11 +1,13 @@ import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, script } from "@ickb/testkit"; +import { byte32FromByte, headerLike, script } from "@ickb/testkit"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import process from "node:process"; import { tmpdir } from "node:os"; import { describe, expect, it, vi } from "vitest"; import { + assertChainPreflight, + CHAIN_IDENTITIES, createPublicClient, formatCkb, handleLoopError, @@ -16,10 +18,15 @@ import { readRuntimeConfigEnv, parseSleepInterval, randomSleepIntervalMs, + readChainPreflight, + redactRpcUrl, + redactSecretText, reachedMaxIterations, signerAccountLocks, STOP_EXIT_CODE, + verifyChainPreflight, writeJsonLine, + type ChainPreflightClient, } from "./index.js"; describe("node utilities", () => { @@ -74,6 +81,7 @@ describe("node utilities", () => { it("parses private keys as exact 0x-prefixed lowercase hex", () => { const privateKey = `0x${"11".repeat(32)}`; + const secp256k1Order = "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; expect(parsePrivateKey(privateKey, "BOT_CONFIG_FILE")).toBe(privateKey); for (const value of [ @@ -83,6 +91,9 @@ describe("node utilities", () => { ` 0x${"11".repeat(32)}`, `0x${"11".repeat(32)} `, `0x${"11".repeat(31)}`, + `0x${"00".repeat(32)}`, + secp256k1Order, + "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142", ]) { expect(() => parsePrivateKey(value, "BOT_CONFIG_FILE")).toThrow( "Invalid env BOT_CONFIG_FILE", @@ -148,6 +159,7 @@ describe("node utilities", () => { it("reads runtime JSON config from a file env source", async () => { const privateKey = `0x${"11".repeat(32)}`; const dir = await mkdtemp(join(tmpdir(), "ickb-runtime-config-")); + const originalInitCwd = process.env.INIT_CWD; try { const configPath = join(dir, "config.json"); await writeFile(configPath, JSON.stringify({ @@ -170,7 +182,20 @@ describe("node utilities", () => { await expect(readRuntimeConfigEnv(join(dir, "missing"), "BOT_CONFIG_FILE")).rejects.toThrow( "Invalid file from env BOT_CONFIG_FILE", ); + process.env.INIT_CWD = dir; + await expect(readRuntimeConfigEnv("config.json", "BOT_CONFIG_FILE")).resolves.toMatchObject({ + chain: "testnet", + privateKey, + }); + await expect(readRuntimeConfigEnv(resolve("config.json"), "BOT_CONFIG_FILE")).rejects.toThrow( + "Invalid file from env BOT_CONFIG_FILE", + ); } finally { + if (originalInitCwd === undefined) { + delete process.env.INIT_CWD; + } else { + process.env.INIT_CWD = originalInitCwd; + } await rm(dir, { recursive: true, force: true }); } }); @@ -205,6 +230,133 @@ describe("node utilities", () => { ); }); + it("pins official CKB chain identities for preflight checks", () => { + expect(CHAIN_IDENTITIES.mainnet).toMatchObject({ + chain: "mainnet", + networkName: "ckb", + genesisHash: "0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5", + genesisMessage: "lina 0x18e020f6b1237a3d06b75121f25a7efa0550e4b3f44f974822f471902424c104", + genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/mainnet.toml", + addressPrefix: "ckb", + }); + expect(CHAIN_IDENTITIES.testnet).toMatchObject({ + chain: "testnet", + networkName: "ckb_testnet", + genesisHash: "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606", + genesisMessage: "aggron-v4", + genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/testnet.toml", + addressPrefix: "ckt", + }); + }); + + it("reads and verifies public chain identity evidence", async () => { + const client = preflightClient({ + addressPrefix: "ckt", + genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, + tipHash: byte32FromByte("22"), + tipNumber: 123n, + tipTimestamp: 456n, + url: "https://user:pass@testnet.example/rpc/path?token=secret&plain=value", + }); + + await expect(readChainPreflight(client, "testnet")).resolves.toEqual({ + chain: "testnet", + redactedRpcUrl: "https://redacted:redacted@testnet.example/...?token=redacted&plain=redacted", + expected: CHAIN_IDENTITIES.testnet, + observed: { + genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, + addressPrefix: "ckt", + tip: { + hash: byte32FromByte("22"), + number: 123n, + timestamp: 456n, + }, + }, + matches: { + genesisHash: true, + addressPrefix: true, + }, + }); + await expect(verifyChainPreflight(client, "testnet")).resolves.toMatchObject({ + chain: "testnet", + matches: { genesisHash: true, addressPrefix: true }, + }); + }); + + it("rejects mismatched public chain identity evidence", () => { + expect(() => assertChainPreflight({ + chain: "testnet", + redactedRpcUrl: "https://rpc.example/", + expected: CHAIN_IDENTITIES.testnet, + observed: { + genesisHash: CHAIN_IDENTITIES.mainnet.genesisHash, + addressPrefix: "ckb", + tip: { hash: byte32FromByte("22"), number: 1n, timestamp: 2n }, + }, + matches: { genesisHash: false, addressPrefix: false }, + })).toThrow( + "Invalid testnet RPC chain identity: genesis hash expected " + + CHAIN_IDENTITIES.testnet.genesisHash + + " observed " + + CHAIN_IDENTITIES.mainnet.genesisHash + + "; address prefix expected ckt observed ckb", + ); + }); + + it("redacts RPC URLs when chain preflight reads fail", async () => { + const client = preflightClient({ + addressPrefix: "ckt", + genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, + tipHash: byte32FromByte("22"), + tipNumber: 123n, + tipTimestamp: 456n, + url: "https://user:pass@testnet.example/rpc/path?token=secret", + }); + client.getHeaderByNumber = (): Promise => { + throw new Error("RPC failed: https://user:pass@testnet.example/rpc/path?token=secret user pass secret"); + }; + + await expect(verifyChainPreflight(client, "testnet")).rejects.toThrow( + "RPC failed: https://redacted:redacted@testnet.example/...?token=redacted", + ); + await expect(verifyChainPreflight(client, "testnet")).rejects.not.toThrow(/secret|user:pass/u); + await expect(verifyChainPreflight(client, "testnet")).rejects.not.toThrow(/\buser\b|\bpass\b/u); + }); + + it("redacts credential-bearing RPC URLs", () => { + expect(redactRpcUrl("https://rpc.example/")).toBe("https://rpc.example/"); + expect(redactRpcUrl("https://rpc.example/path?token=abc&key=def")).toBe( + "https://rpc.example/...?token=redacted&key=redacted", + ); + expect(redactRpcUrl("not a url")).toBe(""); + }); + + it("redacts runtime secrets from text", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + + expect(redactSecretText( + `failed for ${privateKey} via ${rpcUrl}`, + { privateKey, rpcUrl }, + )).toBe( + "failed for via https://redacted:redacted@testnet.example/...?token=redacted", + ); + expect(redactSecretText( + "fetch failed for https://testnet.example/rpc/path?token=secret auth user:pass", + { rpcUrl }, + )).toBe( + "fetch failed for https://testnet.example/rpc/path?token= auth " + + ":", + ); + expect(redactSecretText( + "fetch failed for token=a%2Fb decoded=a/b plain=value", + { rpcUrl: "https://testnet.example/rpc/path?token=a%2Fb&plain=value" }, + )).toBe( + "fetch failed for token= decoded= " + + "plain=", + ); + }); + it("serializes error-like values for JSON logs", () => { const executionLog: Record = {}; @@ -247,6 +399,43 @@ describe("node utilities", () => { process.exitCode = undefined; }); + it("redacts runtime secrets from loop errors", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + const error = new Error(`failed for ${privateKey} via ${rpcUrl}`); + error.stack = `stack with ${privateKey} and ${rpcUrl}`; + const executionLog: Record = {}; + + expect(handleLoopError(executionLog, error, { privateKey, rpcUrl })).toBe(false); + const serialized = JSON.stringify(executionLog); + + expect(serialized).not.toContain(privateKey); + expect(serialized).not.toContain("user:pass"); + expect(serialized).not.toContain("secret"); + expect(serialized).toContain(""); + expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted"); + }); + + it("redacts runtime secrets from non-Error loop failures", () => { + const privateKey = `0x${"11".repeat(32)}`; + const rpcUrl = "https://user:pass@testnet.example/rpc/path?token=secret"; + const executionLog: Record = {}; + + expect(handleLoopError(executionLog, { + message: `failed for ${privateKey}`, + rpcUrl, + amount: 9007199254740993n, + }, { privateKey, rpcUrl })).toBe(false); + const serialized = JSON.stringify(executionLog); + + expect(serialized).not.toContain(privateKey); + expect(serialized).not.toContain("user:pass"); + expect(serialized).not.toContain("secret"); + expect(serialized).toContain(""); + expect(serialized).toContain("https://redacted:redacted@testnet.example/...?token=redacted"); + expect(executionLog.error).toMatchObject({ amount: "9007199254740993" }); + }); + it("logs one JSON entry with elapsed seconds", () => { const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const now = vi.spyOn(Date, "now").mockReturnValue(2500); @@ -348,3 +537,35 @@ function transactionError(isTimeout: boolean, txHash = byte32FromByte("11")): Er isTimeout, }); } + +function preflightClient({ + addressPrefix, + genesisHash, + tipHash, + tipNumber, + tipTimestamp, + url, +}: { + addressPrefix: string; + genesisHash: `0x${string}`; + tipHash: `0x${string}`; + tipNumber: bigint; + tipTimestamp: bigint; + url: string; +}): ChainPreflightClient { + return { + addressPrefix, + url, + getHeaderByNumber: async (blockNumber): Promise => { + await Promise.resolve(); + if (blockNumber !== 0n) { + return; + } + return headerLike({ hash: genesisHash, number: 0n }); + }, + getTipHeader: async (): Promise => { + await Promise.resolve(); + return headerLike({ hash: tipHash, number: tipNumber, timestamp: tipTimestamp }); + }, + }; +} diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index 072a043..91f21b1 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -1,15 +1,235 @@ import { ccc } from "@ckb-ccc/core"; import { unique } from "@ickb/utils"; import { readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; import process from "node:process"; import { setTimeout } from "node:timers"; const CKB = 100000000n; +const SECP256K1_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); export const STOP_EXIT_CODE = 2; export type SupportedChain = "mainnet" | "testnet"; +export interface ChainIdentity { + chain: SupportedChain; + networkName: string; + genesisHash: ccc.Hex; + genesisMessage: string; + genesisSource: string; + addressPrefix: "ckb" | "ckt"; +} + +export const CHAIN_IDENTITIES = { + mainnet: { + chain: "mainnet", + networkName: "ckb", + genesisHash: "0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5", + genesisMessage: "lina 0x18e020f6b1237a3d06b75121f25a7efa0550e4b3f44f974822f471902424c104", + genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/mainnet.toml", + addressPrefix: "ckb", + }, + testnet: { + chain: "testnet", + networkName: "ckb_testnet", + genesisHash: "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606", + genesisMessage: "aggron-v4", + genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/testnet.toml", + addressPrefix: "ckt", + }, +} as const satisfies Record; + +export type ChainPreflightClient = Pick< + ccc.Client, + "addressPrefix" | "getHeaderByNumber" | "getTipHeader" | "url" +>; + +export interface ChainPreflightEvidence { + chain: SupportedChain; + redactedRpcUrl: string; + expected: ChainIdentity; + observed: { + genesisHash: ccc.Hex; + addressPrefix: string; + tip: { + hash: ccc.Hex; + number: bigint; + timestamp: bigint; + }; + }; + matches: { + genesisHash: boolean; + addressPrefix: boolean; + }; +} + +export function expectedChainIdentity(chain: SupportedChain): ChainIdentity { + return CHAIN_IDENTITIES[chain]; +} + +export async function readChainPreflight( + client: ChainPreflightClient, + chain: SupportedChain, +): Promise { + const expected = expectedChainIdentity(chain); + const [genesis, tip] = await Promise.all([ + client.getHeaderByNumber(0n), + client.getTipHeader(), + ]); + + if (genesis === undefined) { + throw new Error(`Missing ${chain} genesis header`); + } + + return { + chain, + redactedRpcUrl: redactRpcUrl(client.url), + expected, + observed: { + genesisHash: genesis.hash, + addressPrefix: client.addressPrefix, + tip: { + hash: tip.hash, + number: tip.number, + timestamp: tip.timestamp, + }, + }, + matches: { + genesisHash: genesis.hash === expected.genesisHash, + addressPrefix: client.addressPrefix === expected.addressPrefix, + }, + }; +} + +export function assertChainPreflight( + evidence: ChainPreflightEvidence, +): ChainPreflightEvidence { + const failures: string[] = []; + if (evidence.observed.genesisHash !== evidence.expected.genesisHash) { + failures.push( + `genesis hash expected ${evidence.expected.genesisHash} observed ${evidence.observed.genesisHash}`, + ); + } + if (evidence.observed.addressPrefix !== evidence.expected.addressPrefix) { + failures.push( + `address prefix expected ${evidence.expected.addressPrefix} observed ${evidence.observed.addressPrefix}`, + ); + } + if (failures.length > 0) { + throw new Error(`Invalid ${evidence.chain} RPC chain identity: ${failures.join("; ")}`); + } + + return evidence; +} + +export async function verifyChainPreflight( + client: ChainPreflightClient, + chain: SupportedChain, +): Promise { + try { + return assertChainPreflight(await readChainPreflight(client, chain)); + } catch (error) { + throw new Error(redactRpcUrlInError(error, client.url)); + } +} + +function redactRpcUrlInError(error: unknown, rpcUrl: string): string { + const message = error instanceof Error ? error.message : "Unknown error"; + return redactSecretText(message, { rpcUrl }); +} + +export function redactSecretText(text: string, secrets: SecretRedactionContext = {}): string { + let redacted = text; + if (secrets.privateKey !== undefined) { + redacted = redacted.split(secrets.privateKey).join(""); + } + if (secrets.rpcUrl !== undefined) { + redacted = redacted.split(secrets.rpcUrl).join(secrets.redactedRpcUrl ?? redactRpcUrl(secrets.rpcUrl)); + redacted = redactRpcUrlSecrets(redacted, secrets.rpcUrl); + } + return redacted; +} + +function redactRpcUrlSecrets(text: string, rpcUrl: string): string { + let url: URL; + try { + url = new URL(rpcUrl); + } catch { + return text; + } + + const replacements = new Array<[string, string]>(); + if (url.username !== "") { + replacements.push([url.username, ""]); + replacements.push([decodeURIComponent(url.username), ""]); + } + if (url.password !== "") { + replacements.push([url.password, ""]); + replacements.push([decodeURIComponent(url.password), ""]); + } + for (const value of url.searchParams.values()) { + replacements.push([value, ""]); + } + for (const value of rawSearchParamValues(url.search)) { + replacements.push([value, ""]); + } + return replaceUrlSecrets(text, replacements); +} + +function rawSearchParamValues(search: string): string[] { + const query = search.startsWith("?") ? search.slice(1) : search; + if (query === "") { + return []; + } + return query.split("&").map((part) => { + const separator = part.indexOf("="); + return separator === -1 ? "" : part.slice(separator + 1); + }); +} + +function replaceUrlSecrets(text: string, replacements: Array<[string, string]>): string { + const unique = new Map(replacements.filter(([secret]) => secret !== "")); + if (unique.size === 0) { + return text; + } + const pattern = [...unique.keys()] + .sort((left, right) => right.length - left.length) + .map(escapeRegExp) + .join("|"); + return text.replace(new RegExp(pattern, "gu"), (match) => unique.get(match) ?? match); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +export function redactRpcUrl(rpcUrl: string): string { + let url: URL; + try { + url = new URL(rpcUrl); + } catch { + return ""; + } + + if (url.username !== "" || url.password !== "") { + url.username = "redacted"; + url.password = url.password === "" ? "" : "redacted"; + } + if (url.pathname !== "" && url.pathname !== "/") { + url.pathname = "/..."; + } + if (url.search !== "") { + const redactedParams = new URLSearchParams(); + for (const [key] of url.searchParams) { + redactedParams.append(key, "redacted"); + } + url.search = redactedParams.toString(); + } + + return url.toString(); +} + export function formatCkb(balance: bigint): string { const sign = balance < 0n ? "-" : ""; const absolute = balance < 0n ? -balance : balance; @@ -40,7 +260,10 @@ export function parseSleepInterval( export function parsePrivateKey(privateKey: string, envName: string): `0x${string}` { if (/^0x[0-9a-f]{64}$/u.test(privateKey)) { - return privateKey as `0x${string}`; + const value = BigInt(privateKey); + if (value > 0n && value < SECP256K1_ORDER) { + return privateKey as `0x${string}`; + } } throw new Error("Invalid env " + envName); @@ -54,6 +277,12 @@ export type RuntimeConfig = { maxIterations: number | undefined; }; +export interface SecretRedactionContext { + privateKey?: string; + rpcUrl?: string; + redactedRpcUrl?: string; +} + export function parseRpcUrl(rpcUrl: string, envName: string): string { for (let index = 0; index < rpcUrl.length; index += 1) { const code = rpcUrl.charCodeAt(index); @@ -163,7 +392,9 @@ export async function readRuntimeConfigEnv( } async function readFileEnv(fileEnvValue: string, fileEnvName: string): Promise { - const secretPath = fileEnvValue; + const secretPath = isAbsolute(fileEnvValue) + ? fileEnvValue + : resolve(process.env.INIT_CWD ?? process.cwd(), fileEnvValue); let fileSecret: string; try { fileSecret = await readFile(secretPath, "utf8"); @@ -196,14 +427,14 @@ export async function signerAccountLocks( ])]; } -function errorToLog(error: unknown): unknown { +function errorToLog(error: unknown, secrets: SecretRedactionContext = {}): unknown { if (error instanceof Object && "stack" in error) { - const stack = error.stack ?? ""; + const stack = redactSecretText(typeof error.stack === "string" ? error.stack : "", secrets); return { name: "name" in error ? error.name : undefined, message: "message" in error && typeof error.message === "string" - ? error.message + ? redactSecretText(error.message, secrets) : "Unknown error", txHash: "txHash" in error ? error.txHash : undefined, status: "status" in error ? error.status : undefined, @@ -211,6 +442,18 @@ function errorToLog(error: unknown): unknown { }; } + if (typeof error === "object" && error !== null) { + try { + return JSON.parse(redactSecretText(JSON.stringify(error, jsonLogReplacer), secrets)) as unknown; + } catch { + return { message: "Non-Error object (unserializable)" }; + } + } + + if (typeof error === "string") { + return redactSecretText(error, secrets); + } + return error ?? "Empty Error"; } @@ -224,8 +467,9 @@ function shouldStopAfterError(error: unknown): boolean { export function handleLoopError( executionLog: Record, error: unknown, + secrets: SecretRedactionContext = {}, ): boolean { - executionLog.error = errorToLog(error); + executionLog.error = errorToLog(error, secrets); if (shouldStopAfterError(error)) { process.exitCode = STOP_EXIT_CODE; return true; diff --git a/scripts/ickb-generate-config.mjs b/scripts/ickb-generate-config.mjs new file mode 100644 index 0000000..21dbda9 --- /dev/null +++ b/scripts/ickb-generate-config.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, realpath, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const rootDir = fileURLToPath(new URL("..", import.meta.url)); +const SECP256K1_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); +const DEFAULT_RPC_URLS = { + mainnet: "https://mainnet.ckb.dev/", + testnet: "https://testnet.ckb.dev/", +}; + +export function parseArgs(argv) { + const args = { + chain: "testnet", + role: "bot", + sleepIntervalSeconds: 1, + maxIterations: 1, + force: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + if (arg === "-h" || arg === "--help") { + args.help = true; + continue; + } + if (arg === "--chain") { + args.chain = parseChain(valueAfter(argv, ++index, arg)); + continue; + } + if (arg === "--role") { + args.role = parseRole(valueAfter(argv, ++index, arg)); + continue; + } + if (arg === "--out") { + args.out = valueAfter(argv, ++index, arg); + continue; + } + if (arg === "--rpc-url") { + args.rpcUrl = parseRpcUrl(valueAfter(argv, ++index, arg)); + continue; + } + if (arg === "--sleep-interval-seconds") { + args.sleepIntervalSeconds = parsePositiveInteger(valueAfter(argv, ++index, arg), arg); + continue; + } + if (arg === "--max-iterations") { + args.maxIterations = parsePositiveInteger(valueAfter(argv, ++index, arg), arg); + continue; + } + if (arg === "--no-max-iterations") { + args.maxIterations = undefined; + continue; + } + if (arg === "--force") { + args.force = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + args.rpcUrl ??= DEFAULT_RPC_URLS[args.chain]; + args.out ??= `config/${args.role}-${args.chain}.json`; + return args; +} + +export function usage() { + return [ + "Usage: node scripts/ickb-generate-config.mjs [--chain testnet|mainnet] [--role