Setting the file. One moment.
Secret Value Guard · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 39.41
PLUGIN READ PLANS
lib/ secret-value-guard.js
JavaScript · 234 lines · 10 KB
// corpus tests/fixtures/secret-patterns/conformance.json
15 // dialect anchors, character classes, alternation, quantifiers. NOTHING else --
16 // no lookaround, no backreferences, no named groups, no inline flags,
17 // no \p{...}, and no `~` (the PHP side's pattern delimiter).
18 //
19 // The dialect is not stylistic. It is the set of constructs that mean the SAME thing in
20 // PCRE and in JavaScript, which is the only reason one pattern string can drive both
21 // engines. assertRestrictedDialect() below is the machine-checkable statement of it, and
22 // the contract test runs it over every manifest entry.
23 //
24 // CI verifies THIS side against the corpus. The PHP side has no interpreter in this
25 // pipeline and is established by review against the same
26 // corpus plus the live smoke. Said out loud because an unverified second implementation of
27 // a security control reads as covered and is not.
28 //
29 // And the standing caveat, the same one the PHP file carries: this is DEFENCE IN DEPTH,
30 // NOT A GUARANTEE. It is a pattern list; a secret in an unrecognized format passes
31 // through. A credential that can be named in advance gets a deterministic denial instead
32 // (the plugin's COLUMN_DENYLIST) and must never be left to this file.
33
34 const fs = require ( 'node:fs' );
35 const path = require ( 'node:path' );
36
37 const REDACTED = '[REDACTED:secret-shaped]' ;
38
39 // VENDORED INSIDE THE SKILL, deliberately. `skills/` is the published product and is
40 // installed on its own, so anything under it that reaches up into the monorepo -- as this
41 // once did, via ../../../../../plugins/wix-wp-plugin-v2/schemas/ -- resolves fine in this
42 // repo and dies with ENOENT the moment a user installs the bundle. Caught by copying
43 // the skill tree alone and calling the guard.
44 //
45 // The plugin's copy at plugins/wix-wp-plugin-v2/schemas/secret-value-patterns.v1.json stays
46 // CANONICAL: the PHP evaluator reads it from
47 // inside the plugin zip for the same no-network reason. So there are now three copies of one
48 // file -- canonical, plugin zip, skill bundle -- and all three are asserted byte-identical
49 // by tests/plugins/wmh2-eav-access-contract-test.js. Drift fails CI rather than producing
50 // guards that disagree.
51 const MANIFEST_PATH = path. join (__dirname, '..' , 'secret-value-patterns.v1.json' );
52
53 // Constructs that do NOT survive the PCRE/JavaScript round trip, or that would break the
54 // PHP side's `~` delimiter. Checked as source-text patterns because the point is to reject
55 // the pattern STRING before either engine ever compiles it.
56 const FORBIDDEN_DIALECT_CONSTRUCTS = [
57 { id: 'lookaround' , test: / \( ( \? = | \? ! | \? <= | \? <!)/ , why: 'lookahead/lookbehind' },
58 { id: 'named-group' , test: / \(\? </ , why: 'named capture group' },
59 { id: 'inline-flags' , test: / \(\? [a-zA-Z] * [-:)] / , why: 'inline flag / non-capturing modifier group' },
60 { id: 'unicode-property' , test: / \\ [pP] \{ / , why: ' \\ p{...} unicode property' },
61 { id: 'backreference' , test: / \\ [1-9] / , why: 'backreference' },
62 { id: 'php-delimiter' , test: /~/ , why: '`~`, the PHP side \' s pattern delimiter' },
63 ];
64
65 /**
66 * Throws unless `regex` uses only constructs that mean the same thing in PCRE and in JS.
67 * Returns the compiled RegExp so a caller cannot accidentally validate one string and
68 * compile another.
69 */
70 function assertRestrictedDialect ( regex , id ) {
71 const label = id ? `denyPatterns[${ id }]` : 'pattern' ;
72 if ( typeof regex !== 'string' || regex === '' ) {
73 throw new Error ( `${ label }: regex must be a non-empty string` );
74 }
75 for ( const construct of FORBIDDEN_DIALECT_CONSTRUCTS ) {
76 if (construct.test. test (regex)) {
77 throw new Error ( `${ label }: ${ construct . why } is outside the restricted dialect (${ construct . id })` );
78 }
79 }
80 try {
81 // No flags, deliberately. `m` would change what `^`/`$` mean; `i` would change what
82 // the character classes mean; either would desynchronize the two engines.
83 return new RegExp (regex);
84 } catch (error) {
85 throw new Error ( `${ label }: not a valid regular expression (${ error . message })` );
86 }
87 }
88
89 const MANIFEST_KEYS = [ 'schemaVersion' , 'allowPrefixes' , 'denyPrefixes' , 'denyPatterns' ];
90
91 /**
92 * STRICT, AND WHOLE-FILE: any defect THROWS, rather than dropping the bad entry and carrying
93 * on with what is left.
94 *
95 * The lenient version of this function was a security hole and a quiet one. `Array.isArray(x)
96 * ? x : []` turned a `denyPrefixes` corrupted to a string into a manifest with NO deny
97 * prefixes; nothing threw, nothing reported a problem, and `whsec_...` came back unredacted
98 * from a guard that looked healthy. Partial corruption is not a lesser problem than total
99 * corruption — it is the same problem, harder to notice.
100 */
101 function loadManifest ( manifestPath = MANIFEST_PATH ) {
102 let raw;
103 try {
104 raw = JSON . parse (fs. readFileSync (manifestPath, 'utf8' ));
105 } catch (error) {
106 throw new Error ( `secret-value manifest at ${ manifestPath } could not be read as JSON: ${ error . message }` );
107 }
108 if ( ! raw || typeof raw !== 'object' || Array. isArray (raw)) {
109 throw new Error ( 'secret-value manifest must be a JSON object' );
110 }
111 const keys = Object. keys (raw). sort ();
112 if ( JSON . stringify (keys) !== JSON . stringify ([ ... MANIFEST_KEYS ]. sort ())) {
113 throw new Error ( `secret-value manifest keys must be exactly: ${ MANIFEST_KEYS . join ( ', ' ) } (got: ${ keys . join ( ', ' ) || 'none'})` );
114 }
115 if (raw.schemaVersion !== 1 ) {
116 throw new Error ( `secret-value manifest schemaVersion ${ JSON . stringify ( raw . schemaVersion ) } is unsupported (this evaluator understands 1)` );
117 }
118 const stringList = ( name ) => {
119 const list = raw[name];
120 if ( ! Array. isArray (list) || list. length === 0 ) throw new Error ( `secret-value manifest ${ name } must be a non-empty array` );
121 for ( const entry of list) {
122 if ( typeof entry !== 'string' || entry === '' ) throw new Error ( `secret-value manifest ${ name } must contain only non-empty strings` );
123 }
124 return [ ... list];
125 };
126 const allowPrefixes = stringList ( 'allowPrefixes' );
127 const denyPrefixes = stringList ( 'denyPrefixes' );
128 if ( ! Array. isArray (raw.denyPatterns) || raw.denyPatterns. length === 0 ) {
129 throw new Error ( 'secret-value manifest denyPatterns must be a non-empty array' );
130 }
131 const denyPatterns = raw.denyPatterns. map (( entry ) => {
132 if ( ! entry || typeof entry !== 'object' || Array. isArray (entry)) throw new Error ( 'every denyPatterns entry must be an object' );
133 const entryKeys = Object. keys (entry). sort ();
134 if ( JSON . stringify (entryKeys) !== JSON . stringify ([ 'id' , 'regex' ])) {
135 throw new Error ( `every denyPatterns entry must have exactly id and regex (got: ${ entryKeys . join ( ', ' ) || 'none'})` );
136 }
137 if ( typeof entry.id !== 'string' || entry.id === '' ) throw new Error ( 'denyPatterns id must be a non-empty string' );
138 return { id: entry.id, regex: entry.regex, compiled: assertRestrictedDialect (entry.regex, entry.id) };
139 });
140 return { schemaVersion: raw.schemaVersion, allowPrefixes, denyPrefixes, denyPatterns };
141 }
142
143 let cachedManifest = null ;
144 let cachedError = null ;
145 function manifest () {
146 if (cachedError !== null ) throw cachedError;
147 if (cachedManifest === null ) {
148 try {
149 cachedManifest = loadManifest ();
150 } catch (error) {
151 // Cached so the SECOND call fails identically to the first. A guard that throws once and
152 // then quietly succeeds is worse than one that consistently refuses.
153 cachedError = error;
154 throw error;
155 }
156 }
157 return cachedManifest;
158 }
159
160 /**
161 * Is the guard operational? The Node counterpart of the plugin's `is_usable()`.
162 *
163 * Its absence was the actual leak: the PHP side
164 * refused a read when its manifest was broken, while this side loaded an empty pattern set and
165 * returned values unredacted. Two implementations of one control disagreeing about whether the
166 * control is even running is the worst version of the two-runtime problem.
167 *
168 * Callers that must not proceed unguarded — the gateway wrapper above all — should
169 * check this before reading, rather than relying on isSecretShaped() to throw mid-loop.
170 */
171 function isUsable () {
172 try {
173 manifest ();
174 return true ;
175 } catch {
176 return false ;
177 }
178 }
179
180 /** Why the guard is unusable, or null when it is fine. */
181 function unusableReason () {
182 try {
183 manifest ();
184 return null ;
185 } catch (error) {
186 return error.message;
187 }
188 }
189
190 /**
191 * The verdict for one value, and the whole guard.
192 *
193 * ORDER IS THE RULE: allowPrefixes first, and as a PREFIX, never a substring. That
194 * ordering is what keeps the payment pointers this effort exists to migrate intact — a
195 * 44-character `card_...` id is precisely what the `long-opaque` pattern would otherwise
196 * eat. Prefix-not-substring is also what stops the allow list becoming a bypass: a value
197 * cannot smuggle `sk_live_...` through by containing `pm_` somewhere inside it.
198 *
199 * Non-strings are never secret-shaped. Coercing an integer column to a string to test it
200 * would only invent false positives on long numeric ids.
201 */
202 function isSecretShaped ( value , loaded = manifest ()) {
203 // Note the default argument: a broken manifest THROWS here rather than reporting "not
204 // secret-shaped". Refusing loudly is the only safe answer a boolean-returning function can
205 // give when it cannot actually evaluate the question.
206 if ( typeof value !== 'string' || value === '' ) return false ;
207 for ( const prefix of loaded.allowPrefixes) {
208 if (value. startsWith (prefix)) return false ;
209 }
210 for ( const prefix of loaded.denyPrefixes) {
211 if (value. startsWith (prefix)) return true ;
212 }
213 for ( const pattern of loaded.denyPatterns) {
214 if (pattern.compiled. test (value)) return true ;
215 }
216 return false ;
217 }
218
219 /** `[REDACTED:secret-shaped]` — never a hash, never a prefix, never the length. */
220 function redact ( value , loaded = manifest ()) {
221 return isSecretShaped (value, loaded) ? REDACTED : value;
222 }
223
224 module . exports = {
225 REDACTED,
226 MANIFEST_PATH,
227 isUsable,
228 unusableReason,
229 FORBIDDEN_DIALECT_CONSTRUCTS,
230 assertRestrictedDialect,
231 loadManifest,
232 isSecretShaped,
233 redact,
234 };