@gatedpath/preflight 0.1.0 → 0.2.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/dist/checks/admin.d.ts +5 -0
- package/dist/checks/admin.js +25 -4
- package/dist/checks/deployer.js +3 -1
- package/dist/checks/git-secrets.d.ts +2 -0
- package/dist/checks/git-secrets.js +9 -1
- package/dist/checks/slither.js +15 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/run.js +26 -2
- package/dist/secrets.d.ts +5 -0
- package/dist/secrets.js +32 -5
- package/dist/ship-cli.js +2 -2
- package/dist/ship.d.ts +9 -0
- package/dist/ship.js +16 -0
- package/dist/types.d.ts +8 -1
- package/package.json +8 -7
package/dist/checks/admin.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare const SAFE_141_CODE_HASHES: {
|
|
|
9
9
|
readonly safeL2Singleton: "0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff";
|
|
10
10
|
readonly safeProxyFactory: "0x50c3cdc4074750a7a974204a716c999edd37482f907608d960b2b025ee0b3317";
|
|
11
11
|
readonly safeFallbackHandler: "0x7c6007a5d711cea8dfd5d91f5940ec29c7f200fe511eb1fc1397b367af3c42f9";
|
|
12
|
+
/** The 171-byte runtime code of a SafeProxy made by that factory, the same at every address; derived 2026-09-19 from the fixtures (code hash). */
|
|
13
|
+
readonly safeProxy: "0xd7d408ebcd99b2b70be43e20253d6d92a8ea8fab29bd3be7f55b10032331fb4c";
|
|
12
14
|
};
|
|
13
15
|
/** keccak256 of runtime code given as a 0x hex string, hashing the bytes rather than the text. */
|
|
14
16
|
export declare function codeHash(codeHex: string): string;
|
|
@@ -18,7 +20,10 @@ export interface AdminReading {
|
|
|
18
20
|
singleton: string | null;
|
|
19
21
|
singletonKind: 'safeSingleton' | 'safeL2Singleton' | null;
|
|
20
22
|
singletonCodeMatches: boolean | null;
|
|
23
|
+
/** The admin's own runtime code is the SafeProxy 1.4.1 code. Its answers prove nothing; its code does. */
|
|
24
|
+
proxyCodeMatches: boolean;
|
|
21
25
|
threshold: number | null;
|
|
26
|
+
ownerCount: number | null;
|
|
22
27
|
}
|
|
23
28
|
export declare function readAdmin(address: string, rpc: string, network: 'mainnet' | 'testnet'): Promise<AdminReading>;
|
|
24
29
|
export declare function checkAdminSafe(admin: string | null, rpc: string, chain: number | null): Promise<CheckResult>;
|
package/dist/checks/admin.js
CHANGED
|
@@ -13,8 +13,11 @@ export const SAFE_141_CODE_HASHES = {
|
|
|
13
13
|
safeL2Singleton: '0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff',
|
|
14
14
|
safeProxyFactory: '0x50c3cdc4074750a7a974204a716c999edd37482f907608d960b2b025ee0b3317',
|
|
15
15
|
safeFallbackHandler: '0x7c6007a5d711cea8dfd5d91f5940ec29c7f200fe511eb1fc1397b367af3c42f9',
|
|
16
|
+
/** The 171-byte runtime code of a SafeProxy made by that factory, the same at every address; derived 2026-09-19 from the fixtures (code hash). */
|
|
17
|
+
safeProxy: '0xd7d408ebcd99b2b70be43e20253d6d92a8ea8fab29bd3be7f55b10032331fb4c',
|
|
16
18
|
};
|
|
17
19
|
const GET_THRESHOLD = '0xe75235b8'; // getThreshold()
|
|
20
|
+
const GET_OWNERS = '0xa0e67e2b'; // getOwners()
|
|
18
21
|
/** keccak256 of runtime code given as a 0x hex string, hashing the bytes rather than the text. */
|
|
19
22
|
export function codeHash(codeHex) {
|
|
20
23
|
return keccak256Hex(Buffer.from(codeHex.replace(/^0x/, ''), 'hex'));
|
|
@@ -22,7 +25,8 @@ export function codeHash(codeHex) {
|
|
|
22
25
|
export async function readAdmin(address, rpc, network) {
|
|
23
26
|
const caller = toCaller(rpc);
|
|
24
27
|
const code = (await caller('eth_getCode', [address, 'latest']));
|
|
25
|
-
const
|
|
28
|
+
const isContract = code !== '0x' && code !== '0x0' && code.length > 2;
|
|
29
|
+
const reading = { address, isContract, singleton: null, singletonKind: null, singletonCodeMatches: null, proxyCodeMatches: isContract && codeHash(code) === SAFE_141_CODE_HASHES.safeProxy, threshold: null, ownerCount: null };
|
|
26
30
|
if (!reading.isContract)
|
|
27
31
|
return reading;
|
|
28
32
|
const slot0 = (await caller('eth_getStorageAt', [address, '0x0', 'latest']));
|
|
@@ -45,11 +49,24 @@ export async function readAdmin(address, rpc, network) {
|
|
|
45
49
|
catch {
|
|
46
50
|
reading.threshold = null;
|
|
47
51
|
}
|
|
52
|
+
try {
|
|
53
|
+
// address[]: an offset word, then the length word
|
|
54
|
+
const ret = await ethCall(rpc, address, GET_OWNERS);
|
|
55
|
+
if (ret && ret.length >= 2 + 128)
|
|
56
|
+
reading.ownerCount = Number(BigInt('0x' + ret.slice(2 + 64, 2 + 128)));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
reading.ownerCount = null;
|
|
60
|
+
}
|
|
48
61
|
return reading;
|
|
49
62
|
}
|
|
50
63
|
export async function checkAdminSafe(admin, rpc, chain) {
|
|
51
|
-
if (!admin)
|
|
52
|
-
|
|
64
|
+
if (!admin) {
|
|
65
|
+
// On mainnet "no admin" is not something to skip past: the report would read ok with nobody named to own the contracts.
|
|
66
|
+
return chain === 151
|
|
67
|
+
? { id: 'admin-safe', status: 'fail', reason: 'No admin given. On mainnet the admin must be named and must be a Safe 1.4.1 with a threshold of at least 2; pass --admin <0x…> or set ADMIN_SAFE.' }
|
|
68
|
+
: { id: 'admin-safe', status: 'skip', reason: 'No admin given; pass --admin <0x…> with the address that will own the deployed contracts.' };
|
|
69
|
+
}
|
|
53
70
|
if (!/^0x[0-9a-fA-F]{40}$/.test(admin))
|
|
54
71
|
return { id: 'admin-safe', status: 'fail', reason: `"${admin}" is not a 20-byte hex address.` };
|
|
55
72
|
if (chain !== 151 && chain !== 153)
|
|
@@ -69,6 +86,8 @@ export async function checkAdminSafe(admin, rpc, chain) {
|
|
|
69
86
|
? { id: 'admin-safe', status: 'fail', reason: `Admin ${admin} is an externally owned account; on mainnet the admin must be a Safe 1.4.1 with a threshold of at least 2.`, data }
|
|
70
87
|
: { id: 'admin-safe', status: 'warn', reason: `Admin ${admin} is an externally owned account; fine on testnet, but mainnet pre-flight will refuse it, so put a Safe in place now.`, data };
|
|
71
88
|
}
|
|
89
|
+
if (!r.proxyCodeMatches)
|
|
90
|
+
return { id: 'admin-safe', status: bad, reason: `Admin ${admin} is a contract but not a SafeProxy 1.4.1: its own runtime code does not hash to the proxy the canonical factory creates. What it answers to getThreshold() proves nothing.`, data };
|
|
72
91
|
if (!r.singletonKind)
|
|
73
92
|
return { id: 'admin-safe', status: bad, reason: `Admin ${admin} is a contract but its storage slot 0 (${r.singleton}) is not the canonical Safe 1.4.1 singleton or SafeL2 singleton on this chain.`, data };
|
|
74
93
|
if (r.singletonCodeMatches === false)
|
|
@@ -77,5 +96,7 @@ export async function checkAdminSafe(admin, rpc, chain) {
|
|
|
77
96
|
return { id: 'admin-safe', status: bad, reason: `Admin ${admin} looks like a Safe but getThreshold() did not answer; the proxy may not be set up.`, data };
|
|
78
97
|
if (r.threshold < 2)
|
|
79
98
|
return { id: 'admin-safe', status: bad, reason: `Admin ${admin} is a Safe 1.4.1 with threshold ${r.threshold}; one signer is one stolen key away from your contracts, so use 2 or more.`, data };
|
|
80
|
-
|
|
99
|
+
if (r.ownerCount === null || r.ownerCount < r.threshold)
|
|
100
|
+
return { id: 'admin-safe', status: bad, reason: `Admin ${admin} is a Safe 1.4.1 with threshold ${r.threshold} but ${r.ownerCount ?? 'an unreadable number of'} owners; it could never sign.`, data };
|
|
101
|
+
return { id: 'admin-safe', status: 'pass', reason: `Admin ${admin} is a Safe 1.4.1 (${r.singletonKind === 'safeL2Singleton' ? 'SafeL2' : 'Safe'} singleton; proxy and singleton code hashes verified) with threshold ${r.threshold} of ${r.ownerCount} owners.`, data };
|
|
81
102
|
}
|
package/dist/checks/deployer.js
CHANGED
|
@@ -38,7 +38,9 @@ export async function checkDeployerVerified(address, how, error, rpc, chain) {
|
|
|
38
38
|
if (isWellKnownDevAccount(address)) {
|
|
39
39
|
return {
|
|
40
40
|
id: 'deployer-verified',
|
|
41
|
-
|
|
41
|
+
// Anyone can sign as this account. On mainnet that is a refusal, and so is failing isAllowed
|
|
42
|
+
// anywhere; a warning is only right on testnet when the account does pass.
|
|
43
|
+
status: chain === 151 || !allowed ? 'fail' : 'warn',
|
|
42
44
|
reason: `${address} (${how}) is one of the ten anvil/Hardhat default accounts whose private keys are public; it ${allowed ? 'passes' : 'fails'} isAllowed on chain ${chain}, but a deploy from it runs under an identity you do not control (RESEARCH.md question 30). Use a keystore of your own.`,
|
|
43
45
|
data: { address, allowed, how, wellKnownDevAccount: true },
|
|
44
46
|
};
|
|
@@ -5,6 +5,8 @@ export interface SecretFinding {
|
|
|
5
5
|
kind: string;
|
|
6
6
|
why: string;
|
|
7
7
|
}
|
|
8
|
+
/** True when the history scan leaves this path alone. Exported for the tests. */
|
|
9
|
+
export declare function ignoredPath(file: string): boolean;
|
|
8
10
|
export declare function scanGitHistory(project: string): Promise<{
|
|
9
11
|
findings: SecretFinding[];
|
|
10
12
|
commits: number;
|
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { createInterface } from 'node:readline';
|
|
7
7
|
import { scanLine } from '../secrets.js';
|
|
8
|
-
|
|
8
|
+
// Vendor and build roots only, where a project keeps them: the repository root, `contracts/` or
|
|
9
|
+
// `hardhat/`. `node_modules` is vendor at any depth. A folder that merely shares a name, such as
|
|
10
|
+
// `src/lib/wallet.ts`, is the builder's own code and is scanned (audit of 2026-09-19). The Slither
|
|
11
|
+
// sidecar is a bare hash by design.
|
|
12
|
+
const IGNORED_PATHS = /(^|\/)node_modules\/|^((contracts|hardhat)\/)?(lib|out|cache|artifacts|typechain(-types)?)\/|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|\.gas-snapshot)$|\.(min\.js|map|svg|png|jpg|pdf|sha256)$/;
|
|
13
|
+
/** True when the history scan leaves this path alone. Exported for the tests. */
|
|
14
|
+
export function ignoredPath(file) {
|
|
15
|
+
return IGNORED_PATHS.test(file);
|
|
16
|
+
}
|
|
9
17
|
const MAX_FINDINGS = 25;
|
|
10
18
|
export async function scanGitHistory(project) {
|
|
11
19
|
const isRepo = await run('git', ['rev-parse', '--is-inside-work-tree'], project).then((r) => r.code === 0 && r.stdout.trim() === 'true');
|
package/dist/checks/slither.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// 2026-09-12 running the prompt cards), which is why the hash sidecar exists.
|
|
11
11
|
import { execFileSync } from 'node:child_process';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync, realpathSync } from 'node:fs';
|
|
14
14
|
import { dirname, join, relative, sep } from 'node:path';
|
|
15
15
|
import { run } from '../proc.js';
|
|
16
16
|
export const DEFAULT_REPORT_PATHS = ['reports/slither.json', 'reports/slither.md', 'slither.json', 'slither-report.json', 'slither.md'];
|
|
@@ -95,10 +95,21 @@ async function fileTimes(project, files) {
|
|
|
95
95
|
out.set(f, mtime(f));
|
|
96
96
|
return out;
|
|
97
97
|
}
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// `-z`: NUL-separated and unquoted, so a path with a space is not wrapped in quotes. Both sides are
|
|
99
|
+
// resolved to real paths: git prints the repository's real root, while `project` may have been
|
|
100
|
+
// reached through a symlink (`/tmp` on macOS). Compared as plain strings, an edited source was
|
|
101
|
+
// never seen and a stale report passed (audit of 2026-09-19).
|
|
102
|
+
const status = await run('git', ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...files], { cwd: project, timeoutMs: 30_000 });
|
|
103
|
+
const real = (f) => { try {
|
|
104
|
+
return realpathSync(f);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return f;
|
|
108
|
+
} };
|
|
109
|
+
const root = repoRoot(project);
|
|
110
|
+
const dirty = new Set(status.stdout.split('\0').filter(Boolean).map((l) => real(join(root, l.slice(3)))));
|
|
100
111
|
for (const f of files) {
|
|
101
|
-
if (dirty.has(f)) {
|
|
112
|
+
if (dirty.has(real(f))) {
|
|
102
113
|
out.set(f, mtime(f));
|
|
103
114
|
continue;
|
|
104
115
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,9 +3,9 @@ export type { RunOptions } from './run.js';
|
|
|
3
3
|
export type { CheckId, CheckResult, CheckStatus, PreflightOptions, PreflightReport } from './types.js';
|
|
4
4
|
export { readProjectConfig, parseToml, readFoundryConfig, readHardhatConfig, HARDHAT_CONFIG_NAMES } from './config.js';
|
|
5
5
|
export type { ProjectConfig } from './config.js';
|
|
6
|
-
export { scanLine, scanText, scanValue, mnemonicRun } from './secrets.js';
|
|
6
|
+
export { scanLine, scanText, scanValue, mnemonicRun, provenHashes } from './secrets.js';
|
|
7
7
|
export type { SecretKind, SecretMatch } from './secrets.js';
|
|
8
|
-
export { scanGitHistory, checkGitSecrets } from './checks/git-secrets.js';
|
|
8
|
+
export { scanGitHistory, checkGitSecrets, ignoredPath } from './checks/git-secrets.js';
|
|
9
9
|
export { readAdmin, checkAdminSafe, codeHash, SAFE_141_CODE_HASHES } from './checks/admin.js';
|
|
10
10
|
export { checkCompilerPins, SOLC_PIN, EVM_PIN } from './checks/compiler.js';
|
|
11
11
|
export { checkBalance, MARGIN_PERCENT } from './checks/balance.js';
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { runPreflight, renderText, CHECK_IDS, DEFAULT_GAS, VERSION } from './run.js';
|
|
2
2
|
export { readProjectConfig, parseToml, readFoundryConfig, readHardhatConfig, HARDHAT_CONFIG_NAMES } from './config.js';
|
|
3
|
-
export { scanLine, scanText, scanValue, mnemonicRun } from './secrets.js';
|
|
4
|
-
export { scanGitHistory, checkGitSecrets } from './checks/git-secrets.js';
|
|
3
|
+
export { scanLine, scanText, scanValue, mnemonicRun, provenHashes } from './secrets.js';
|
|
4
|
+
export { scanGitHistory, checkGitSecrets, ignoredPath } from './checks/git-secrets.js';
|
|
5
5
|
export { readAdmin, checkAdminSafe, codeHash, SAFE_141_CODE_HASHES } from './checks/admin.js';
|
|
6
6
|
export { checkCompilerPins, SOLC_PIN, EVM_PIN } from './checks/compiler.js';
|
|
7
7
|
export { checkBalance, MARGIN_PERCENT } from './checks/balance.js';
|
package/dist/run.js
CHANGED
|
@@ -70,9 +70,17 @@ export async function runPreflight(opts = {}) {
|
|
|
70
70
|
deployer: deployer.address,
|
|
71
71
|
admin,
|
|
72
72
|
checks,
|
|
73
|
-
ok: checks.every((c) => c.status !== 'fail'),
|
|
73
|
+
ok: checks.every((c) => c.status !== 'fail') && !skipsForbidOk(skip, reported ?? expected),
|
|
74
|
+
skippedOnRequest: skip.size,
|
|
74
75
|
};
|
|
75
76
|
}
|
|
77
|
+
/** A requested skip that "ok" cannot survive: anything on mainnet, the chain check anywhere, or everything. */
|
|
78
|
+
function skipsForbidOk(skip, chain) {
|
|
79
|
+
if (skip.size === 0)
|
|
80
|
+
return false;
|
|
81
|
+
return chain === 151 || skip.has('chain-id') || skip.size >= ALL_CHECKS;
|
|
82
|
+
}
|
|
83
|
+
const ALL_CHECKS = 7;
|
|
76
84
|
function defaultRpc(chain) {
|
|
77
85
|
const c = chainById(chain === 151 ? 151 : 153);
|
|
78
86
|
if (!c)
|
|
@@ -101,6 +109,22 @@ export function renderText(report) {
|
|
|
101
109
|
lines.push('');
|
|
102
110
|
const fails = report.checks.filter((c) => c.status === 'fail').length;
|
|
103
111
|
const warns = report.checks.filter((c) => c.status === 'warn').length;
|
|
104
|
-
|
|
112
|
+
const skipped = report.skippedOnRequest;
|
|
113
|
+
if (report.ok) {
|
|
114
|
+
const notes = [warns ? `${warns} warning${warns === 1 ? '' : 's'}` : '', skipped ? `${skipped} skipped on request, so not checked` : ''].filter(Boolean);
|
|
115
|
+
lines.push(`ok: no check failed${notes.length ? ` (${notes.join('; ')})` : ''}.`);
|
|
116
|
+
}
|
|
117
|
+
else if (fails > 0) {
|
|
118
|
+
lines.push(`not ok: ${fails} check${fails === 1 ? '' : 's'} failed. Do not deploy.`);
|
|
119
|
+
}
|
|
120
|
+
else if (skipped >= ALL_CHECKS) {
|
|
121
|
+
lines.push('not ok: every check was skipped, so nothing was checked.');
|
|
122
|
+
}
|
|
123
|
+
else if (report.chain.reported === 151 || report.chain.expected === 151) {
|
|
124
|
+
lines.push(`not ok: ${skipped} check${skipped === 1 ? ' was' : 's were'} skipped on request, and chain 151 accepts no skipped check.`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
lines.push('not ok: the chain check was skipped, so nothing says which network this is.');
|
|
128
|
+
}
|
|
105
129
|
return lines.join('\n') + '\n';
|
|
106
130
|
}
|
package/dist/secrets.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ export interface SecretMatch {
|
|
|
4
4
|
/** Why this counts. One sentence, no secret material. */
|
|
5
5
|
readonly why: string;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Hashes a line proves about itself: `keccak256("X")` beside a value that really is keccak256 of X,
|
|
9
|
+
* where X is shaped like a role name. A key cannot be dressed up that way without its preimage.
|
|
10
|
+
*/
|
|
11
|
+
export declare function provenHashes(line: string): Set<string>;
|
|
7
12
|
/** Every secret-shaped thing on one line of text. */
|
|
8
13
|
export declare function scanLine(line: string): SecretMatch[];
|
|
9
14
|
/** True when the line holds 12+ consecutive BIP-39 words separated by single spaces. */
|
package/dist/secrets.js
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
// Spots secret-shaped text. Used by the git-history check here and by the MCP server's
|
|
2
2
|
// argument guard. It reports the kind of match and where, never the matched text.
|
|
3
|
+
import { keccak256Hex } from '@gatedpath/chains';
|
|
3
4
|
import { BIP39_ENGLISH } from './bip39-words.js';
|
|
4
5
|
const HEX64 = /(?<![0-9a-fA-F])(0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/g;
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
// Word edges that treat `_` as an edge. `\b` does not: `_` is a word character, so `\bPRIVATE_KEY\b`
|
|
7
|
+
// never fired inside REDBELLY_PRIVATE_KEY, and that shape passed the scan (audit of 2026-09-19).
|
|
8
|
+
const L = '(?<![A-Za-z0-9])';
|
|
9
|
+
const R = '(?![A-Za-z0-9])';
|
|
10
|
+
/** Words that make a 0x 64-hex value a key whatever else the line says. */
|
|
11
|
+
const KEY_WORDS = new RegExp(`${L}(private[_ -]?key|priv[_ -]?key|privkey|pk|secret|signer[_ -]?key|deployer[_ -]?key|wallet[_ -]?key|mnemonic|seed|startBroadcast|privateKeyToAccount)${R}`, 'i');
|
|
12
|
+
/** Weaker signs: a key is likely, unless the line is plainly about a hash. A Hardhat `accounts` array is the commonest leak there is. */
|
|
13
|
+
const KEYISH_WORDS = new RegExp(`${L}(accounts?|wallet|signer|key)${R}|new Wallet\\(`, 'i');
|
|
7
14
|
/** Lines that are plainly about hashes stay quiet even without the 0x prefix. */
|
|
8
|
-
const HASH_WORDS = /"id"\s*:|\b(sha-?256|sha-?512|sha-?3|keccak|blake2?b?|integrity|checksum|digest|codehash|code hash|tx ?hash|transactionHash|blockHash|merkle|root)\b/i;
|
|
9
|
-
const KEY_ASSIGNMENT =
|
|
15
|
+
const HASH_WORDS = /"id"\s*:|\b(sha-?256|sha-?512|sha-?3|keccak|blake2?b?|integrity|checksum|digest|codehash|code hash|tx ?hash|transactionHash|blockHash|merkle|root|slot|topic|salt)\b|hash/i;
|
|
16
|
+
const KEY_ASSIGNMENT = new RegExp(`${L}((?:[A-Z0-9]+_)*(?:PRIVATE_KEY|PRIV_KEY|DEPLOYER_KEY|DEPLOYER_PRIVATE_KEY|WALLET_KEY|SIGNER_KEY|MNEMONIC|SEED_PHRASE|SEED|PK))${R}\\s*[=:]\\s*["'\`]?([^\\s"'\`#]+(?:\\s+[a-z]+)*)`);
|
|
10
17
|
const PLACEHOLDER = /^(<|\$|\{\{|your|xxx+|\.\.\.|changeme|replace|example|0x0+$|todo|none|null|undefined|""|'')/i;
|
|
11
18
|
const KEYSTORE = /"(ciphertext|kdfparams|mac)"\s*:/;
|
|
19
|
+
/**
|
|
20
|
+
* Hashes a line proves about itself: `keccak256("X")` beside a value that really is keccak256 of X,
|
|
21
|
+
* where X is shaped like a role name. A key cannot be dressed up that way without its preimage.
|
|
22
|
+
*/
|
|
23
|
+
export function provenHashes(line) {
|
|
24
|
+
const proven = new Set();
|
|
25
|
+
for (const m of line.matchAll(/keccak256\(\\?"([A-Z][A-Z0-9_]*)\\?"\)/g)) {
|
|
26
|
+
proven.add(keccak256Hex(new TextEncoder().encode(m[1])).toLowerCase());
|
|
27
|
+
}
|
|
28
|
+
return proven;
|
|
29
|
+
}
|
|
12
30
|
/** Every secret-shaped thing on one line of text. */
|
|
13
31
|
export function scanLine(line) {
|
|
14
32
|
const out = [];
|
|
@@ -17,8 +35,17 @@ export function scanLine(line) {
|
|
|
17
35
|
for (const m of line.matchAll(HEX64)) {
|
|
18
36
|
const prefixed = m[1] === '0x';
|
|
19
37
|
if (prefixed) {
|
|
20
|
-
|
|
38
|
+
const proven = provenHashes(line);
|
|
39
|
+
if (proven.has(m[0].toLowerCase()))
|
|
40
|
+
continue;
|
|
41
|
+
if (proven.size > 0) {
|
|
42
|
+
// The line names a preimage, and this value is not its hash: whatever it is, it is not what it says.
|
|
43
|
+
out.push({ kind: 'hex64-keyword', why: 'a 0x-prefixed 64-hex value beside a keccak256("...") that does not hash to it' });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (KEY_WORDS.test(line) || (KEYISH_WORDS.test(line) && !HASH_WORDS.test(line))) {
|
|
21
47
|
out.push({ kind: 'hex64-keyword', why: 'a 0x-prefixed 64-hex value on a line that talks about a key' });
|
|
48
|
+
}
|
|
22
49
|
}
|
|
23
50
|
else if (!HASH_WORDS.test(line)) {
|
|
24
51
|
out.push({ kind: 'hex64', why: 'a bare 64-hex value, the shape of a private key in a .env or keystore export' });
|
package/dist/ship-cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `redbelly ship`: the mainnet gate that writes its own report. Exit 0 when every check passed and
|
|
2
2
|
// deployments/ship-<chain>-<date>.md was written, 1 when a check failed (nothing written), 2 on bad
|
|
3
3
|
// arguments. --json for agents. Never takes a key: --account names a keystore that cast reads.
|
|
4
|
-
import { SHIP_VERSION, renderShipText, runShip, shipReportJson } from './ship.js';
|
|
4
|
+
import { SHIP_VERSION, renderShipText, runShip, shipReportJson, ShipUsageError } from './ship.js';
|
|
5
5
|
export const SHIP_HELP = `redbelly ship --chain <151|153> --account <keystore-name> [options]
|
|
6
6
|
|
|
7
7
|
Runs pre-flight (seven checks) with the keystore's address, checks the Slither sidecar, runs
|
|
@@ -123,6 +123,6 @@ export async function shipMain(argv = process.argv.slice(2)) {
|
|
|
123
123
|
}
|
|
124
124
|
catch (e) {
|
|
125
125
|
process.stderr.write(`redbelly ship: ${e.message}\n`);
|
|
126
|
-
return 1;
|
|
126
|
+
return e instanceof ShipUsageError ? 2 : 1;
|
|
127
127
|
}
|
|
128
128
|
}
|
package/dist/ship.d.ts
CHANGED
|
@@ -103,6 +103,15 @@ export declare function routescanBase(chain: 151 | 153): {
|
|
|
103
103
|
/** The deployment records the deploy script wrote for this chain, with the verify command each needs. */
|
|
104
104
|
export declare function readShipContracts(dir: string, chain: 151 | 153, pins: ShipReport['pins']): ShipContract[];
|
|
105
105
|
export declare function runShip(opts: ShipOptions): Promise<ShipReport>;
|
|
106
|
+
/**
|
|
107
|
+
* The deploy scripts find today's report by its name, `ship-<chain>-<date>.md`. `--out` may put the
|
|
108
|
+
* report anywhere, except under a name that says it is for a different chain: a passing testnet
|
|
109
|
+
* report must never sit where the mainnet gate looks (audit of 2026-09-19).
|
|
110
|
+
*/
|
|
111
|
+
export declare function refuseAnotherChainsName(out: string, chain: number): void;
|
|
112
|
+
/** A mistake in how ship was called, as opposed to a check that failed. The CLI exits 2 on it. */
|
|
113
|
+
export declare class ShipUsageError extends Error {
|
|
114
|
+
}
|
|
106
115
|
export declare function renderShipMarkdown(r: ShipReport): string;
|
|
107
116
|
export declare function renderShipText(r: ShipReport): string;
|
|
108
117
|
export declare function shipReportJson(report: ShipReport): string;
|
package/dist/ship.js
CHANGED
|
@@ -98,6 +98,8 @@ export async function runShip(opts) {
|
|
|
98
98
|
if (!existsSync(join(dir, 'foundry.toml')))
|
|
99
99
|
throw new Error(`no foundry.toml in ${project} or ${join(project, 'contracts')}; ship drives forge and reads the Foundry project`);
|
|
100
100
|
const chain = opts.chain;
|
|
101
|
+
if (opts.out)
|
|
102
|
+
refuseAnotherChainsName(resolve(dir, opts.out), chain); // before any work: this is a usage mistake
|
|
101
103
|
const network = chain === 151 ? 'mainnet' : 'testnet';
|
|
102
104
|
const dotenv = readDotEnv(dir, env);
|
|
103
105
|
const rootEnv = dir === project ? dotenv : { ...readDotEnv(project, env), ...dotenv };
|
|
@@ -267,6 +269,20 @@ export async function runShip(opts) {
|
|
|
267
269
|
writeFileSync(out, renderShipMarkdown(report));
|
|
268
270
|
return { ...report, written: out };
|
|
269
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* The deploy scripts find today's report by its name, `ship-<chain>-<date>.md`. `--out` may put the
|
|
274
|
+
* report anywhere, except under a name that says it is for a different chain: a passing testnet
|
|
275
|
+
* report must never sit where the mainnet gate looks (audit of 2026-09-19).
|
|
276
|
+
*/
|
|
277
|
+
export function refuseAnotherChainsName(out, chain) {
|
|
278
|
+
const named = /^ship-(\d+)-/.exec(basename(out));
|
|
279
|
+
if (named && Number(named[1]) !== chain) {
|
|
280
|
+
throw new ShipUsageError(`--out names a ship report for chain ${named[1]}, and this run is for chain ${chain}. Choose another name, or leave --out off.`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** A mistake in how ship was called, as opposed to a check that failed. The CLI exits 2 on it. */
|
|
284
|
+
export class ShipUsageError extends Error {
|
|
285
|
+
}
|
|
270
286
|
// ---- the document ----
|
|
271
287
|
const fmt = (n) => n.toLocaleString('en-US');
|
|
272
288
|
const short = (s) => (s && s.includes('.') ? s.replace(/(\.\d{4})\d+$/, '$1') : s ?? '');
|
package/dist/types.d.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface PreflightReport {
|
|
|
53
53
|
readonly deployer: string | null;
|
|
54
54
|
readonly admin: string | null;
|
|
55
55
|
readonly checks: readonly CheckResult[];
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* True when no check failed and the run checked something. A check skipped because it does not
|
|
58
|
+
* apply is neutral. A check skipped ON REQUEST is neutral on testnet, and makes this false on
|
|
59
|
+
* chain 151 or when the chain check itself, or every check, was skipped: "ok" must never mean
|
|
60
|
+
* "nothing was looked at".
|
|
61
|
+
*/
|
|
57
62
|
readonly ok: boolean;
|
|
63
|
+
/** How many checks `--skip` removed. */
|
|
64
|
+
readonly skippedOnRequest: number;
|
|
58
65
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gatedpath/preflight",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pre-flight checks before deploying to Redbelly Network (151 mainnet, 153 testnet): chain ID, deployer verification, Safe admin, compiler pins, secrets in git history, RBNT balance, Slither report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"bin": {
|
|
28
|
-
"redbelly-preflight": "
|
|
29
|
-
"redbelly-doctor": "
|
|
30
|
-
"redbelly": "
|
|
28
|
+
"redbelly-preflight": "dist/cli.js",
|
|
29
|
+
"redbelly-doctor": "dist/doctor-cli.js",
|
|
30
|
+
"redbelly": "dist/redbelly.js"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
@@ -40,11 +40,12 @@
|
|
|
40
40
|
"prepack": "npm run build"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@gatedpath/agent-rules": "0.
|
|
44
|
-
"@gatedpath/chains": "0.
|
|
43
|
+
"@gatedpath/agent-rules": "0.2.0",
|
|
44
|
+
"@gatedpath/chains": "0.2.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "^22.20.2",
|
|
48
48
|
"typescript": "^5.9.3"
|
|
49
|
-
}
|
|
49
|
+
},
|
|
50
|
+
"preferUnplugged": true
|
|
50
51
|
}
|