Skip to content

Commit dedb5dd

Browse files
authored
chore: Validate envs and secrets during build time (MetaMask#29634)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This safeguard is a follow up to catch malformed envs and secrets during build time to prevent issues similar to incident [1578](https://consensys.slack.com/archives/C0AUU2AT0C9/p1777390058735369). If any issues are detected during build time, the `Build Mobile App` workflow will fail with a message listing the offending value ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MCWP-564 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Tightens CI validation by failing builds on previously-accepted env/secret values (e.g., trailing newlines/whitespace), which may block releases if any existing configuration relies on those formats. Risk is limited to build/CI scripts but impacts all build pipelines consuming `builds.yml` and GitHub secrets. > > **Overview** > Adds a shared `checkValue` validator for CI-injected configuration values, flagging missing/empty values plus common paste artifacts (leading/trailing whitespace, `\r`/CRLF, NUL/control chars, and zero-width Unicode) while ensuring error messages never include secret contents. > > Build-time checks are expanded to run this hygiene validation across `builds.yml` `env` entries (with a small allowlist for intentionally-empty keys) and across required secrets in `validate-secrets-from-config.js`, which now emits GitHub Actions `::error` annotations and reports all offenders in one pass. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 165a500. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a47e728 commit dedb5dd

4 files changed

Lines changed: 389 additions & 37 deletions

File tree

scripts/lib/validate-value.js

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Shared hygiene checks for CI-injected values (GitHub secrets and builds.yml env values).
3+
*
4+
* The goal is to catch operator mistakes (trailing newline, Windows line endings,
5+
* invisible characters pasted from rich text editors, etc.) at build time, before
6+
* malformed values end up baked into a production binary.
7+
*
8+
* Intentionally format-agnostic: it does not try to understand whether a value
9+
* is a URL, base64 blob, JWT, etc. It only enforces generic hygiene.
10+
*
11+
* Usage:
12+
* const { checkValue } = require('./lib/validate-value');
13+
* const issues = checkValue('MM_SENTRY_DSN', value);
14+
* // issues is an array; empty => value is clean.
15+
*
16+
* Output contract: issue messages MUST NOT include the value itself or any
17+
* substring of it. Only the length, offsets, and character code points are
18+
* safe to surface.
19+
*/
20+
21+
/* global Buffer */
22+
23+
// eslint-disable-next-line no-misleading-character-class -- intentional set of invisible code points (ZWSP/ZWNJ/ZWJ/BOM)
24+
const ZERO_WIDTH_CHARS = /[\u200B\u200C\u200D\uFEFF]/;
25+
26+
// C0/C1 control chars, excluding tab (\u0009), line feed (\u000A), and
27+
// carriage return (\u000D). CR is reported separately with a friendlier
28+
// message. LF is allowed mid-value for multi-line secrets (PEM keys, base64).
29+
/* eslint-disable no-control-regex -- intentionally matches control characters to flag them */
30+
const CONTROL_CHARS =
31+
/[\u0001-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/;
32+
/* eslint-enable no-control-regex */
33+
34+
function formatCodePoint(ch) {
35+
return `U+${ch.charCodeAt(0).toString(16).padStart(4, '0').toUpperCase()}`;
36+
}
37+
38+
/**
39+
* @param {string} name - Identifier to report in violations (e.g. secret name or env key).
40+
* @param {unknown} value - The value to check. Non-string values are coerced via String().
41+
* @param {object} [options]
42+
* @param {boolean} [options.allowEmpty=false] - If true, an empty string is not a violation.
43+
* Whitespace-only strings still fail even when allowEmpty is true (they indicate a typo).
44+
* @returns {{ code: string, message: string }[]} - One entry per distinct violation; empty array means clean.
45+
*/
46+
function checkValue(name, value, options = {}) {
47+
const { allowEmpty = false } = options;
48+
const violations = [];
49+
50+
/**
51+
* `missing`: the value is `undefined` or `null`. For secrets, this means the
52+
* referenced GitHub Environment secret was never set (or the Environment
53+
* itself is misconfigured). For YAML env entries, it usually means a key
54+
* like `FOO:` was written with no value, which js-yaml parses as `null`.
55+
* Short-circuits: nothing else can be checked without a value.
56+
*/
57+
if (value === undefined || value === null) {
58+
violations.push({
59+
code: 'missing',
60+
message: `${name}: value is null or not defined`,
61+
});
62+
return violations;
63+
}
64+
65+
const str = String(value);
66+
const len = Buffer.byteLength(str, 'utf8');
67+
68+
/**
69+
* `empty`: the value is the empty string `""`. Skipped when the caller
70+
* passes `{ allowEmpty: true }`, used for intentionally-empty YAML entries
71+
* such as optional allowlists (e.g. `MM_PERPS_HIP3_ALLOWLIST_MARKETS: ''`).
72+
* Short-circuits: the remaining checks don't apply to an empty string.
73+
*/
74+
if (str === '') {
75+
if (!allowEmpty) {
76+
violations.push({
77+
code: 'empty',
78+
message: `${name}: value is an empty string`,
79+
});
80+
}
81+
return violations;
82+
}
83+
84+
/**
85+
* `whitespace_only`: the value is non-empty but contains nothing except
86+
* whitespace. Almost always a typo (e.g. someone pasted a single space
87+
* into the GitHub Secret UI). Fails even with `allowEmpty: true`, because
88+
* "intentionally empty" should be `""`, not `" "`.
89+
* Short-circuits: the value has no meaningful content to inspect further.
90+
*/
91+
if (str.trim() === '') {
92+
violations.push({
93+
code: 'whitespace_only',
94+
message: `${name}: value is whitespace-only (${len} bytes)`,
95+
});
96+
return violations;
97+
}
98+
99+
/**
100+
* `leading_whitespace`: the value begins with whitespace (space, tab, LF,
101+
* etc.). Accidental leading whitespace breaks URL parsing, base64 decoding,
102+
* and token comparisons — almost never intentional.
103+
*/
104+
if (/^\s/.test(str)) {
105+
violations.push({
106+
code: 'leading_whitespace',
107+
message: `${name}: value has leading whitespace (${len} bytes total)`,
108+
});
109+
}
110+
111+
/**
112+
* `trailing_whitespace`: the value ends with whitespace. This is the
113+
* single most common real-world paste mistake — e.g. copying a token from
114+
* a terminal or editor ends up including the trailing `\n`. It's the
115+
* specific failure mode this entire module was built to catch.
116+
*/
117+
if (/\s$/.test(str)) {
118+
violations.push({
119+
code: 'trailing_whitespace',
120+
message: `${name}: value has trailing whitespace (${len} bytes total); a trailing newline pasted from a terminal or editor is the most common cause`,
121+
});
122+
}
123+
124+
/**
125+
* `carriage_return`: the value contains `\r` anywhere. CR is not part of
126+
* the base64 alphabet, PEM uses LF, and no sanctioned secret format needs
127+
* it — its presence is essentially always an artifact of Windows CRLF line
128+
* endings surviving a copy-paste. Reported separately from `control_chars`
129+
* so the remediation message can specifically call out Windows endings.
130+
*/
131+
const crIndex = str.indexOf('\r');
132+
if (crIndex !== -1) {
133+
violations.push({
134+
code: 'carriage_return',
135+
message: `${name}: value contains a carriage return (\\r) at offset ${crIndex}/${len}; strip Windows line endings before saving the secret`,
136+
});
137+
}
138+
139+
/**
140+
* `nul_byte`: the value contains `\u0000`. Never legitimate in any secret
141+
* format we use. A NUL byte can terminate strings prematurely in C-based
142+
* tooling and is a classic source of silent truncation bugs.
143+
*/
144+
const nulIndex = str.indexOf('\u0000');
145+
if (nulIndex !== -1) {
146+
violations.push({
147+
code: 'nul_byte',
148+
message: `${name}: value contains a NUL byte at offset ${nulIndex}/${len}`,
149+
});
150+
}
151+
152+
/**
153+
* `control_chars`: the value contains any other C0/C1 control character
154+
* (range defined in CONTROL_CHARS — excludes tab, LF, and CR, which are
155+
* allowed or reported separately). Hits here usually indicate non-text
156+
* binary data was pasted as if it were a string, or a stray escape
157+
* sequence. The message includes the specific code point (e.g. U+0007)
158+
* so operators can identify what was pasted.
159+
*/
160+
const ctrlMatch = CONTROL_CHARS.exec(str);
161+
if (ctrlMatch) {
162+
violations.push({
163+
code: 'control_chars',
164+
message: `${name}: value contains control character ${formatCodePoint(ctrlMatch[0])} at offset ${ctrlMatch.index}/${len}`,
165+
});
166+
}
167+
168+
/**
169+
* `zero_width`: the value contains an invisible Unicode character —
170+
* zero-width space (U+200B), zero-width non-joiner (U+200C), zero-width
171+
* joiner (U+200D), or byte-order mark (U+FEFF). These are introduced when
172+
* copying from rich-text sources (Google Docs, Slack, Notion, Confluence)
173+
* and are invisible to the human eye but break exact-match comparisons
174+
* and break formats like base64 that only accept a strict alphabet.
175+
*/
176+
const zwMatch = ZERO_WIDTH_CHARS.exec(str);
177+
if (zwMatch) {
178+
violations.push({
179+
code: 'zero_width',
180+
message: `${name}: value contains invisible character ${formatCodePoint(zwMatch[0])} at offset ${zwMatch.index}/${len}; likely pasted from a rich text source`,
181+
});
182+
}
183+
184+
return violations;
185+
}
186+
187+
module.exports = { checkValue };

scripts/lib/validate-value.test.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
const { checkValue } = require('./validate-value');
2+
3+
const codes = (issues) => issues.map((i) => i.code);
4+
5+
describe('checkValue', () => {
6+
describe('happy path', () => {
7+
it('returns no issues for a typical single-line secret', () => {
8+
expect(checkValue('X', 'key_live_abc123xyz')).toEqual([]);
9+
});
10+
11+
it('returns no issues for a multi-line base64 blob (embedded \\n mid-value is allowed)', () => {
12+
const b64 = 'eyJhcGlLZXkiOiJhYmMifQ==\nsecond-line-content==';
13+
expect(checkValue('GOOGLE_SERVICES_B64_IOS', b64)).toEqual([]);
14+
});
15+
16+
it('coerces non-string values via String()', () => {
17+
expect(checkValue('N', 42)).toEqual([]);
18+
expect(checkValue('B', true)).toEqual([]);
19+
});
20+
});
21+
22+
describe('missing / empty', () => {
23+
it('flags undefined', () => {
24+
expect(codes(checkValue('X', undefined))).toEqual(['missing']);
25+
});
26+
27+
it('flags null', () => {
28+
expect(codes(checkValue('X', null))).toEqual(['missing']);
29+
});
30+
31+
it('flags empty string by default', () => {
32+
expect(codes(checkValue('X', ''))).toEqual(['empty']);
33+
});
34+
35+
it('allows empty string when allowEmpty=true', () => {
36+
expect(checkValue('X', '', { allowEmpty: true })).toEqual([]);
37+
});
38+
39+
it('flags whitespace-only even when allowEmpty=true', () => {
40+
expect(codes(checkValue('X', ' ', { allowEmpty: true }))).toEqual([
41+
'whitespace_only',
42+
]);
43+
});
44+
});
45+
46+
describe('leading / trailing whitespace', () => {
47+
it('flags trailing newline (the classic paste mistake)', () => {
48+
expect(codes(checkValue('X', 'value\n'))).toEqual([
49+
'trailing_whitespace',
50+
]);
51+
});
52+
53+
it('flags trailing space', () => {
54+
expect(codes(checkValue('X', 'value '))).toEqual([
55+
'trailing_whitespace',
56+
]);
57+
});
58+
59+
it('flags trailing tab', () => {
60+
expect(codes(checkValue('X', 'value\t'))).toEqual([
61+
'trailing_whitespace',
62+
]);
63+
});
64+
65+
it('flags leading space', () => {
66+
expect(codes(checkValue('X', ' value'))).toEqual([
67+
'leading_whitespace',
68+
]);
69+
});
70+
71+
it('flags both leading and trailing whitespace in one pass', () => {
72+
expect(codes(checkValue('X', ' value '))).toEqual([
73+
'leading_whitespace',
74+
'trailing_whitespace',
75+
]);
76+
});
77+
});
78+
79+
describe('control characters', () => {
80+
it('flags any \\r (Windows line endings)', () => {
81+
expect(codes(checkValue('X', 'abc\r\ndef'))).toContain('carriage_return');
82+
});
83+
84+
it('flags a standalone \\r mid-value', () => {
85+
expect(codes(checkValue('X', 'abc\rdef'))).toContain('carriage_return');
86+
});
87+
88+
it('flags NUL bytes', () => {
89+
expect(codes(checkValue('X', 'abc\u0000def'))).toContain('nul_byte');
90+
});
91+
92+
it('flags other C0 control characters', () => {
93+
expect(codes(checkValue('X', 'abc\u0007def'))).toContain(
94+
'control_chars',
95+
);
96+
});
97+
98+
it('flags DEL (U+007F)', () => {
99+
expect(codes(checkValue('X', 'abc\u007Fdef'))).toContain(
100+
'control_chars',
101+
);
102+
});
103+
104+
it('does NOT flag tab mid-value', () => {
105+
expect(checkValue('X', 'abc\tdef')).toEqual([]);
106+
});
107+
108+
it('does NOT flag LF mid-value (allowed for PEM / base64)', () => {
109+
expect(checkValue('X', 'abc\ndef')).toEqual([]);
110+
});
111+
});
112+
113+
describe('invisible characters', () => {
114+
it('flags zero-width space', () => {
115+
expect(codes(checkValue('X', 'abc\u200Bdef'))).toContain('zero_width');
116+
});
117+
118+
it('flags BOM', () => {
119+
expect(codes(checkValue('X', '\uFEFFvalue'))).toEqual(
120+
expect.arrayContaining(['leading_whitespace', 'zero_width']),
121+
);
122+
});
123+
124+
it('flags zero-width joiner', () => {
125+
expect(codes(checkValue('X', 'abc\u200Ddef'))).toContain('zero_width');
126+
});
127+
});
128+
129+
describe('message safety', () => {
130+
it('never includes the value in the message', () => {
131+
const secret = 'super-secret-token-xyz';
132+
const issues = checkValue('X', `${secret}\n`);
133+
for (const { message } of issues) {
134+
expect(message).not.toContain(secret);
135+
}
136+
});
137+
138+
it('includes the name, a byte length, and the code in the output', () => {
139+
const [issue] = checkValue('MM_SENTRY_DSN', 'abc ');
140+
expect(issue.code).toBe('trailing_whitespace');
141+
expect(issue.message).toContain('MM_SENTRY_DSN');
142+
expect(issue.message).toMatch(/\d+ bytes/);
143+
});
144+
});
145+
});

scripts/validate-build-config.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,21 @@
66
const fs = require('fs');
77
const path = require('path');
88
const yaml = require('js-yaml');
9+
const { checkValue } = require('./lib/validate-value');
910

1011
const BUILDS_PATH = path.join(__dirname, '../builds.yml');
1112

13+
// Env keys in builds.yml that may legitimately be the empty string. Every
14+
// other declared env key must have a non-empty value, so an accidental
15+
// `PORTFOLIO_API_URL: ''` (or a botched YAML anchor merge) fails validation
16+
// instead of shipping with a blank value. Adding to this list should be
17+
// deliberate: the review on that PR is the gate for "yes, this key is
18+
// intentionally optional."
19+
const ENV_KEYS_ALLOWED_EMPTY = new Set([
20+
'MM_PERPS_HIP3_ALLOWLIST_MARKETS',
21+
'MM_PERPS_HIP3_BLOCKLIST_MARKETS',
22+
]);
23+
1224
function validate() {
1325
if (!fs.existsSync(BUILDS_PATH)) {
1426
console.error('❌ builds.yml not found');
@@ -46,6 +58,20 @@ function validate() {
4658
if (!build.github_environment) {
4759
errors.push(`${name}: missing github_environment`);
4860
}
61+
62+
// Hygiene checks on env values: catch trailing whitespace, stray \r,
63+
// invisible characters, accidental empties, etc. so operator typos fail
64+
// CI before the build fans out. Strict by default; only the keys in
65+
// ENV_KEYS_ALLOWED_EMPTY may be the empty string. Whitespace-only values
66+
// always fail, since "intentionally empty" should be `''` not `' '`.
67+
if (build.env && typeof build.env === 'object') {
68+
for (const [envKey, envVal] of Object.entries(build.env)) {
69+
const issues = checkValue(`${name}.env.${envKey}`, envVal, {
70+
allowEmpty: ENV_KEYS_ALLOWED_EMPTY.has(envKey),
71+
});
72+
issues.forEach((issue) => errors.push(issue.message));
73+
}
74+
}
4975
});
5076

5177
if (errors.length > 0) {

0 commit comments

Comments
 (0)