From 85038241fba727f946e1dcf306b4a4f49e78f210 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:46:25 +0000 Subject: [PATCH] fix(bundle): carry --manifests files as written, without redaction --manifests redacted what it carried: a carried foundry.toml lost its [rpc_endpoints] and [etherscan] tables and any key named like a secret, and every carried file lost the user info of its URLs. That can't be complete (a key in a query string, a nested inline table, a TOML escape all get past a text or key-name rule), and it makes the bundle carry a file that isn't the one the build used, which is the silent kind of wrong the bundle must not be. --package-json has never edited what it carries either. The files are now carried byte for byte. What decides which files may be carried stays as it was: nothing outside the root, no dependency config whose `extends` leaves the dependency, never `.env` or hardhat.config.*. The docs and the usage text say that the carried files may hold credentials, and to keep them in the environment (`${VAR}` in foundry.toml) or not pass --manifests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm --- doc/file-formats.md | 13 +++++++------ stasis/bin/stasis.js | 2 +- stasis/src/cmd/bundle.js | 8 +++----- stasis/src/loaders/foundry.js | 32 -------------------------------- tests/bundle-cmd.test.js | 16 +++++++++------- tests/solidity-loader.test.js | 27 ++------------------------- 6 files changed, 22 insertions(+), 76 deletions(-) diff --git a/doc/file-formats.md b/doc/file-formats.md index 858106b8..c08c73ba 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -334,12 +334,13 @@ description too: the `*.toml`/`*.txt` config files the resolution read, the root's `foundry.lock`, `soldeer.lock`, `.gitmodules` and `package.json`, and the `package.json`, `foundry.toml` and `remappings.txt` of every package the bundle holds files of — `json` for a `package.json`, `resource` otherwise, so `stasis -extract` restores them. Credentials stay behind: a `.toml` config loses its -`[rpc_endpoints]` and `[etherscan]` tables (at the top level or in a profile) -and keys such as `eth_rpc_url` or `etherscan_api_key` (any key named like a -key, token, secret or password), every carried file loses the user info of its -URLs (`https://user:token@host` is `https://host`), and `hardhat.config.*`, -being code that may hold keys, is never carried. +extract` restores them. They are carried as written, as `--package-json` carries +`package.json`: stasis doesn't edit them, so whatever they hold — an +`eth_rpc_url` or `[rpc_endpoints]` URL with its API key, an `[etherscan]` key, +the credentials in a `.gitmodules` URL — is in the bundle too. Keep secrets in +the environment (`${VAR}` in `foundry.toml`) rather than in these files, or +don't pass `--manifests`. `hardhat.config.*`, being code, and `.env` files are +never carried. Rust entries are crate roots (`src/main.rs`, `src/lib.rs`, `src/bin/*.rs`, `tests/*.rs`, …): their `mod` declarations resolve as siblings, as rustc does, diff --git a/stasis/bin/stasis.js b/stasis/bin/stasis.js index 54bdc473..dbd70abe 100755 --- a/stasis/bin/stasis.js +++ b/stasis/bin/stasis.js @@ -36,7 +36,7 @@ function usage(prefix = '') { remappings that one file lists; an import must reach a .sol file inside the project, and a dependency's only other dependencies' files; --manifests also carries foundry.toml, remappings.txt, foundry.lock, soldeer.lock, .gitmodules and package.json of the project and its - bundled dependencies, without their RPC/Etherscan keys, secret-named keys and URL credentials) + bundled dependencies, as written: RPC/Etherscan keys and URL credentials in them included) stasis bundle [--add] [--output=(path|-)] path/to/file.php ... stasis bundle [--scope=(node_modules|full)] [--conditions=cond1,cond2] [--mainFields=field1,field2] [--jsx] [--flow] [--typescript [--tsconfig=path/to/tsconfig.json]] [--resources=ext,ext] [--package-json] [--lockfile=path/to/stasis.lock.json] [--add] [--output=(path|-)] path/to/file.(js|ts) ... stasis bundle --metro [--metro-resolver] --platforms=ios,android [--platforms=web] [--jsx] [--flow] [--typescript [--tsconfig=path/to/tsconfig.json]] [--resources=ext,ext] [--package-json] [--lockfile=path/to/stasis.lock.json] [--add] [--output=(path|-)] path/to/file.(js|ts) ... diff --git a/stasis/src/cmd/bundle.js b/stasis/src/cmd/bundle.js index 29f736c9..a02ebd5c 100644 --- a/stasis/src/cmd/bundle.js +++ b/stasis/src/cmd/bundle.js @@ -23,7 +23,6 @@ import { discoverSolidityConfig, expandSolidityEntries, } from '../loaders/solidity.js' -import { redactFoundryToml, scrubUrlCredentials } from '../loaders/foundry.js' import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js' import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js' import { VENDOR_DIR as CARGO_VENDOR_DIR, createCargoContext } from '../loaders/cargo.js' @@ -240,8 +239,8 @@ function assembleCodeBundle({ // The build-description files of a Solidity bundle (--manifests), as Map: `configFiles` // (what discoverSolidityConfig read, when named `*.toml`/`*.txt`) plus the SOLIDITY_*_MANIFESTS // that exist, for the root and for each package dir `classifyDep`/package.json places a bundled -// source in. Files inside the root only. Credentials stay behind: a `.toml` config loses its RPC -// endpoint and Etherscan tables and secret-named keys, and every file its URLs' user info. +// source in. Files inside the root only, carried as written: whatever they hold (an RPC URL with +// its API key, an Etherscan key, a URL's credentials) is in the bundle too, as with --package-json. function solidityManifests(baseDir, sources, configFiles, classifyDep) { const wanted = new Set([...configFiles.filter((f) => f.endsWith('.toml') || f.endsWith('.txt')), ...SOLIDITY_ROOT_MANIFESTS]) const dirs = new Set() @@ -267,8 +266,7 @@ function solidityManifests(baseDir, sources, configFiles, classifyDep) { throw err } if (!isUtf8(buf)) throw new Error(`Solidity manifest is not valid UTF-8: ${rel}`) - const text = buf.toString('utf8') - out.set(rel, rel.endsWith('.toml') ? redactFoundryToml(text, rel) : scrubUrlCredentials(text)) + out.set(rel, buf.toString('utf8')) } return out } diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index 64662f2f..4ed3d46e 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -817,35 +817,3 @@ export function foundryProject(baseDir, { env = process.env } = {}) { const envUsed = [...(env.FOUNDRY_PROFILE ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])] return { remappings, libs: config.libs, files: relFiles, envUsed } } - -// --- Carrying configs (`--manifests`) ------------------------------------------------------- - -// What in a foundry.toml holds credentials rather than build settings: the RPC endpoint and -// Etherscan tables (provider URLs embed API keys), wherever they sit, and keys named like one. -const SECRET_TABLES = new Set(['rpc_endpoints', 'etherscan']) -const SECRET_KEY_RE = /^(?:eth_rpc_url|eth_rpc_jwt|eth_rpc_headers|fork_url)$|(?:^|_)(?:api_key|key|secret|token|password|passphrase|mnemonic|private_key|jwt)$/u - -// `scheme://user:password@host` or `scheme://token@host` -> `scheme://host`, anywhere in a text. -export const scrubUrlCredentials = (text) => text.replaceAll(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#@"'<>]+@/giu, '$1') - -// A foundry.toml without its credentials: a SECRET_TABLES table goes whole (every line up to the -// next header), a pair under one or keyed like a secret goes line for line, and URLs lose their -// user info. Everything else is kept verbatim. Throws a TomlError naming `file` on text that -// isn't TOML: what can't be read can't be redacted. -export function redactFoundryToml(text, file = null) { - const lines = text.split('\n') - const entries = tomlEntries(text, { file }) - const drop = new Set() - const isSecretTable = (segs) => segs.some((seg) => SECRET_TABLES.has(seg)) - entries.forEach((e, idx) => { - const segs = e.path.map(snakeCase) - if (e.header) { - if (!isSecretTable(segs)) return - const next = entries.slice(idx + 1).find((x) => x.header) - for (let i = e.first; i < (next ? next.first : lines.length); i++) drop.add(i) - } else if (isSecretTable(segs) || SECRET_KEY_RE.test(segs.at(-1))) { - for (let i = e.first; i <= e.last; i++) drop.add(i) - } - }) - return scrubUrlCredentials(lines.filter((_, i) => !drop.has(i)).join('\n')) -} diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 1aed23ea..9fef8579 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -490,8 +490,8 @@ test('buildSolidityBundle refuses a remapping target outside the project root', )) })) -test('buildSolidityBundle with manifests leaves credentials and a dependency\'s outside `extends` behind', withTmp(async (t, tmp) => { - writeProject(tmp, { +test('buildSolidityBundle with manifests carries configs as written, never `.env`, hardhat.config.* or a dependency\'s outside `extends`', withTmp(async (t, tmp) => { + const files = { 'foundry.toml': [ '[profile.default]', 'src = "src"', @@ -514,14 +514,16 @@ test('buildSolidityBundle with manifests leaves credentials and a dependency\'s 'src/A.sol': 'import "dep/D.sol";\n', 'lib/dep/src/D.sol': 'contract D {}\n', 'lib/dep/foundry.toml': '[profile.default]\nextends = "../../.env"\n', - }) + } + writeProject(tmp, files) const { result: bundle, lines } = await captureStderr(() => buildSolidityBundle({ cwd: tmp, entries: ['src'], manifests: true, env: {} })) - // The submodule's own foundry.toml is carried; the `.env` it names is neither read as config nor carried. + // The configs are carried byte for byte, whatever they hold; stasis doesn't edit them. const carried = [...bundle.sources].filter(([p]) => !p.endsWith('.sol')) t.assert.deepEqual(carried.map(([p]) => p).toSorted(), ['.gitmodules', 'foundry.toml', 'lib/dep/foundry.toml']) - for (const [, text] of carried) t.assert.doesNotMatch(text, /KEY\d/u) - t.assert.equal(bundle.sources.get('foundry.toml'), '[profile.default]\nsrc = "src"\n\n[fmt]\nline_length = 100\n') - t.assert.match(bundle.sources.get('.gitmodules'), /url = https:\/\/github\.com\/o\/dep\.git/u) + for (const [p, text] of carried) t.assert.equal(text, files[p], p) + // `.env` and hardhat.config.* are never carried, and the submodule's `extends` reaching the + // project's `.env` is neither read as config nor carried. + for (const [, text] of bundle.sources) t.assert.doesNotMatch(text, /KEY[67]/u) t.assert.ok(lines.some((l) => l.includes("Skipping a dependency's config") && l.includes('outside the dependency'))) })) diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index 9b28972a..fbe5e89f 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -17,7 +17,7 @@ import { readRemappingsFile, resolveSolImport, } from '../stasis/src/loaders/solidity.js' -import { findRemappingsWithContext, foundryProject, foundryTomlRemappings, redactFoundryToml, scrubUrlCredentials } from '../stasis/src/loaders/foundry.js' +import { findRemappingsWithContext, foundryProject, foundryTomlRemappings } from '../stasis/src/loaders/foundry.js' const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'solidity-bundle') @@ -607,30 +607,7 @@ test('foundry.toml profiles: legacy [] tables, case-insensitive names, quo t.assert.deepEqual(foundryTomlRemappings('remappings = ["@top/=lib/top/"]\n').map((r) => r.name), ['@top/']) }) -test('redactFoundryToml drops RPC/Etherscan tables and secret-named keys, keeping the rest verbatim', (t) => { - const toml = [ - '# build', - '[profile.default]', - 'src = "src"', - 'eth_rpc_url = "https://eth.example/v2/K1"', - 'remappings = [', - ' "a/=b/", # comment', - ']', - 'etherscan = { mainnet = { key = "K2" } }', - '[profile.default.rpc_endpoints]', - 'sepolia = "https://x/K3"', - '[rpc_endpoints]', - '"weird name" = "https://x/K4"', - '[etherscan]', - 'mainnet = { key = "K5" }', - '[fmt]', - 'repo = "https://user:K6@host/r"', - '', - ].join('\n') - t.assert.equal(redactFoundryToml(toml), '# build\n[profile.default]\nsrc = "src"\nremappings = [\n "a/=b/", # comment\n]\n[fmt]\nrepo = "https://host/r"\n') - t.assert.equal(scrubUrlCredentials('https://t@github.com/o/r git@github.com:o/r https://h/p@v1'), 'https://github.com/o/r git@github.com:o/r https://h/p@v1') - // what can't be read as TOML can't be redacted: the file and line are named - t.assert.throws(() => redactFoundryToml('[rpc_endpoints]\nmainnet = "https://k@h" junk\n', 'foundry.toml'), { name: 'TomlError', message: 'foundry.toml:2: unexpected text after the value' }) +test('foundryTomlRemappings names the line of a foundry.toml that isn\'t TOML', (t) => { t.assert.throws(() => foundryTomlRemappings('[profile.default]\nremappings = ["a/=b/"\n'), { name: 'TomlError', message: 'line 2: unterminated array' }) })