@@ -42,6 +42,9 @@ const CREDENTIAL_SHAPED = [
4242 'serviceAccountJson' , 'sharedAccessSignature' ,
4343 // a trailing plural or qualifier
4444 'apiKeys' , 'tokens' , 'passwords' , 'privateKeyPem' , 'apiKeyValue' , 'tokenValue' , 'keyJson' , 'clientSecretValue' ,
45+ // one-word stems, header names, folded compounds
46+ 'pass' , 'pw' , 'pat' , 'authorization' , 'x-auth-token' , 'Proxy-Authorization' , 'set-cookie' , 'service_account_json' ,
47+ 'dbpassword' , 'secretaccesskey' , 'passwordhash' ,
4548] as const ;
4649
4750/** Spellings that must NOT be judged credential-shaped. */
@@ -52,7 +55,10 @@ const NOT_CREDENTIAL_SHAPED = [
5255 'primaryKey' , 'partitionKey' , 'sortKey' , 'cacheKey' , 'idempotencyKey' , 'accessKey' , 'accessKeyId' , 'clientId' ,
5356 'tenantId' ,
5457 // words that merely contain a stem
55- 'passive' , 'bypass' , 'bypassCache' , 'passThrough' , 'cookieDomain' , 'tokenTtl' ,
58+ 'passive' , 'bypass' , 'bypassCache' , 'passThrough' , 'cookieDomain' , 'tokenTtl' , 'compass' , 'author' , 'authorName' ,
59+ 'tokenizer' , 'keyspace' , 'secretary' , 'credentialing' ,
60+ // a stem already ending in `s` takes no plural `s`
61+ 'sass' , 'compileSass' ,
5662 // references, locators, identifiers, descriptors
5763 'credentialsRef' , 'secretArn' , 'secretName' , 'passwordFile' , 'privateKeyPath' , 'tokenUrl' , 'tokenEndpoint' ,
5864 'passwordEnv' , 'tokenType' , 'apiKeyHeader' , 'tokenPrefix' , 'clientSecretId' , 'secretsManagerRegion' ,
@@ -83,6 +89,15 @@ describe('embedded credentials in a string value', () => {
8389 [ 'libpq keyword/value' , 'host=h port=5432 password=p' ] ,
8490 [ 'libpq quoted value' , "host=h password='a b'" ] ,
8591 [ 'scheme-less userinfo' , 'u:p@h/db' ] ,
92+ [ 'scheme-relative userinfo' , '//u:p@h/db' ] ,
93+ [ 'stacked-scheme userinfo' , 'jdbc:postgresql://u:p@h/db' ] ,
94+ [ 'Oracle thin userinfo' , 'jdbc:oracle:thin:scott/tiger@//h:1521/svc' ] ,
95+ [ 'libpq unquoted `;` in a password' , 'host=h password=a;b' ] ,
96+ [ 'URL userinfo with `;` in the password' , 'sqlserver://u:a;b=c@h/db' ] ,
97+ [ 'URL tail property whose value holds `@`' , 'sqlserver://h;password=a@b;databaseName=d' ] ,
98+ [ 'URL tail property whose value holds `:` and `@`' , 'sqlserver://h;password=a:b@c' ] ,
99+ [ 'query pair holding `;`' , 'https://h/x?token=a;b' ] ,
100+ [ 'query pair whose `;` run carries a credential' , 'https://h/x?mode=ro;password=p' ] ,
86101 ] ) ( 'finds %s' , ( _label , value ) => {
87102 expect ( embeddedCredentialOf ( value ) ) . toBeDefined ( ) ;
88103 } ) ;
@@ -96,6 +111,9 @@ describe('embedded credentials in a string value', () => {
96111 [ 'libpq with no credential' , 'host=h port=5432 dbname=d' ] ,
97112 [ 'an email address' , 'ops@example.com' ] ,
98113 [ 'a label' , 'just a label' ] ,
114+ [ 'a stacked-scheme URL with no userinfo' , 'jdbc:postgresql://h/db?ssl=true' ] ,
115+ [ 'an Oracle thin URL with no userinfo' , 'jdbc:oracle:thin:@//h:1521/svc' ] ,
116+ [ 'a scheme-relative URL with no userinfo' , '//h/db' ] ,
99117 ] ) ( 'finds nothing in %s' , ( _label , value ) => {
100118 expect ( embeddedCredentialOf ( value ) ) . toBeUndefined ( ) ;
101119 expect ( redactEmbeddedCredentials ( value ) ) . toBe ( value ) ;
@@ -123,10 +141,27 @@ describe('embedded credentials in a string value', () => {
123141 expect ( redactEmbeddedCredentials ( 'Driver={x};PWD={a;}}b};Database=d' ) ) . toBe ( 'Driver={x};Database=d' ) ;
124142 } ) ;
125143
126- it ( 'an unquoted password containing `;` and `=` takes its whole tail with it (no partial leak)' , ( ) => {
127- const out = redactEmbeddedCredentials ( 'Server=h;Password=ab;cd=ef;gh;Database=d' ) ;
128- expect ( out ) . toBe ( 'Server=h;Database=d' ) ;
129- expect ( out ) . not . toMatch ( / c d | e f | g h / ) ;
144+ it . each ( [
145+ [ 'Server=h;Password=SEK;RIT=a;b;Database=d' , 'Server=h;Database=d' ] ,
146+ [ 'Password=SEK;RIT;Server=h' , 'Server=h' ] ,
147+ [ 'host=h password=SEK;RIT dbname=d' , 'host=h dbname=d' ] ,
148+ [ "host=h password='SEK RIT' dbname=d" , 'host=h dbname=d' ] ,
149+ [ 'sqlserver://u:SEK;RIT=x@h:1433;databaseName=d' , 'sqlserver://u@h:1433;databaseName=d' ] ,
150+ [ 'sqlserver://h;password=SEK;RIT=x;databaseName=d' , 'sqlserver://h;databaseName=d' ] ,
151+ [ 'sqlserver://h;password=SEK@RIT;databaseName=d' , 'sqlserver://h;databaseName=d' ] ,
152+ [ 'sqlserver://h;password=SEK:RIT@x;databaseName=d' , 'sqlserver://h;databaseName=d' ] ,
153+ [ 'https://h/x?token=SEK;RIT&mode=ro' , 'https://h/x?mode=ro' ] ,
154+ [ 'https://h/x?mode=ro;password=SEKRIT#f' , 'https://h/x#f' ] ,
155+ [ '//u:SEKRIT@h/db' , '//u@h/db' ] ,
156+ [ 'jdbc:mysql://u:SEKRIT@h/db?useSSL=true' , 'jdbc:mysql://u@h/db?useSSL=true' ] ,
157+ [ 'jdbc:oracle:thin:scott/SEKRIT@//h:1521/svc' , 'jdbc:oracle:thin:scott@//h:1521/svc' ] ,
158+ ] ) ( 'an unquoted credential holding `;`, `=`, `:` or `@` leaves no tail: %s' , ( value , expected ) => {
159+ expect ( embeddedCredentialOf ( value ) ) . toBeDefined ( ) ;
160+ const out = redactEmbeddedCredentials ( value ) ;
161+ expect ( out ) . toBe ( expected ) ;
162+ expect ( out ) . not . toMatch ( / S E K | R I T / ) ;
163+ // What the read door serves is itself credential-free: the write door accepts it back.
164+ expect ( embeddedCredentialOf ( out ) ) . toBeUndefined ( ) ;
130165 } ) ;
131166} ) ;
132167
@@ -172,6 +207,18 @@ describe('write door: DatasourceSchema refuses inline credentials for a driver w
172207 ) . toEqual ( [ 'config.headers.0.value' , 'config.headers.1.value' , 'config.hosts.0' , 'config.servers.0.password' ] ) ;
173208 } ) ;
174209
210+ it ( 'a credential-shaped key inside array data is refused — array data is no longer off the walk' , ( ) => {
211+ // Inverts the earlier pin that accepted `seed: [{ password: 'row-data' }]`.
212+ expect ( refusals ( { seed : [ { password : 'row-data' } ] } ) ) . toEqual ( [ 'config.seed.0.password' ] ) ;
213+ } ) ;
214+
215+ it ( 'refuses the value of a `[name, value]` header tuple naming a credential, and only that' , ( ) => {
216+ expect (
217+ refusals ( { headers : [ [ 'Authorization' , 'Bearer t' ] , [ 'Cookie' , 'sid=1' ] , [ 'Accept' , 'application/json' ] ] } ) . sort ( ) ,
218+ ) . toEqual ( [ 'config.headers.0.1' , 'config.headers.1.1' ] ) ;
219+ expect ( refusals ( { pairs : [ [ 'token' , '' ] ] , range : [ 'password' , 'x' ] } ) ) . toEqual ( [ ] ) ;
220+ } ) ;
221+
175222 it ( 'control: plain row data in an array is accepted' , ( ) => {
176223 expect ( refusals ( { seed : [ { name : 'a' , amount : 1 } , { name : 'b' , amount : 2 } ] , tags : [ 'x' , 'y' ] } ) ) . toEqual ( [ ] ) ;
177224 } ) ;
@@ -208,8 +255,13 @@ describe('write door: DatasourceSchema refuses inline credentials for a driver w
208255 connectionString : 'Server=h;Password=p' ,
209256 libpq : 'host=h password=p' ,
210257 target : 'u:p@h/db' ,
258+ libpqSemicolon : 'host=h password=a;b' ,
259+ oracle : 'jdbc:oracle:thin:scott/tiger@//h:1521/svc' ,
211260 } ) ,
212- ) . toEqual ( [ 'config.url' , 'config.dsn' , 'config.jdbc' , 'config.connectionString' , 'config.libpq' , 'config.target' ] ) ;
261+ ) . toEqual ( [
262+ 'config.url' , 'config.dsn' , 'config.jdbc' , 'config.connectionString' , 'config.libpq' , 'config.target' ,
263+ 'config.libpqSemicolon' , 'config.oracle' ,
264+ ] ) ;
213265 } ) ;
214266
215267 it ( 'accepts an environment-name placeholder in place of a value — and only that grammar' , ( ) => {
@@ -263,6 +315,7 @@ const STORED = {
263315 credentials : { type : 'service_account' , value : 'sa' } ,
264316 servers : [ { host : 'a' , password : 'p1' } , { host : 'b' } ] ,
265317 headers : [ { name : 'Authorization' , value : 'Bearer t' } , { name : 'Accept' , value : 'application/json' } ] ,
318+ tuples : [ [ 'Authorization' , 'Bearer t2' ] , [ 'Accept' , 'application/json' ] ] ,
266319 connectionString : 'Server=h;User Id=u;Password=p;Database=d' ,
267320 libpq : 'host=h password=p dbname=d' ,
268321 url : 'https://u:p@h/x?mode=ro' ,
@@ -280,6 +333,7 @@ describe('read door: redactDatasourceConfig for a driver with no shipped contrac
280333 credentials : { type : 'service_account' } ,
281334 servers : [ { host : 'a' } , { host : 'b' } ] ,
282335 headers : [ { name : 'Authorization' } , { name : 'Accept' , value : 'application/json' } ] ,
336+ tuples : [ [ 'Authorization' ] , [ 'Accept' , 'application/json' ] ] ,
283337 connectionString : 'Server=h;User Id=u;Database=d' ,
284338 libpq : 'host=h dbname=d' ,
285339 url : 'https://u@h/x?mode=ro' ,
@@ -295,11 +349,43 @@ describe('read door: redactDatasourceConfig for a driver with no shipped contrac
295349 'libpq' ,
296350 'oauth.client_secret' ,
297351 'servers.0.password' ,
352+ 'tuples.0.1' ,
298353 'url' ,
299354 ] ) ;
300355 expect ( JSON . stringify ( config ) ) . not . toMatch ( / " k " | k 1 | " c s " | " s a " | p 1 | B e a r e r | P a s s w o r d = p | p a s s w o r d = p | u : p @ / ) ;
301356 } ) ;
302357
358+ it ( 'withholds a subtree too deep to judge — the same position the write door refuses' , ( ) => {
359+ let deep : Record < string , unknown > = { leaf : 'x' } ;
360+ for ( let i = 0 ; i < 20 ; i += 1 ) deep = { n : deep } ;
361+ const { config, redactedKeys } = redactDatasourceConfig ( DRIVER , { host : 'h' , deep } ) ;
362+ expect ( redactedKeys ) . toHaveLength ( 1 ) ;
363+ expect ( [ 'config' , ...( redactedKeys [ 0 ] as string ) . split ( '.' ) ] . join ( '.' ) ) . toEqual ( refusals ( { host : 'h' , deep } ) [ 0 ] ) ;
364+ expect ( JSON . stringify ( config ) ) . not . toContain ( 'leaf' ) ;
365+ } ) ;
366+
367+ it ( 'an array element withheld before its siblings is nulled, never shifting them; one at the end is spliced' , ( ) => {
368+ // Twenty nested `[inner, 'sib']` pairs: the walk's depth cap lands on an
369+ // `inner` that has a sibling after it.
370+ let nested : unknown = [ 'x' , 'sib' ] ;
371+ for ( let i = 0 ; i < 20 ; i += 1 ) nested = [ nested , 'sib' ] ;
372+ const { config, redactedPaths } = redactDatasourceConfig ( DRIVER , { list : nested } ) ;
373+ expect ( redactedPaths ) . toHaveLength ( 1 ) ;
374+ let node = ( config as { list : unknown } ) . list ;
375+ let levels = 0 ;
376+ while ( Array . isArray ( node ) ) {
377+ expect ( node ) . toHaveLength ( 2 ) ;
378+ expect ( node [ 1 ] ) . toBe ( 'sib' ) ;
379+ node = node [ 0 ] ;
380+ levels += 1 ;
381+ }
382+ expect ( node ) . toBeNull ( ) ;
383+ expect ( levels ) . toBe ( ( redactedPaths [ 0 ] as readonly string [ ] ) . length - 1 ) ;
384+ // A withheld element at the END of its array is spliced.
385+ const tail = redactDatasourceConfig ( DRIVER , { headers : [ [ 'Accept' , 'json' ] , [ 'Authorization' , 'Bearer t' ] ] } ) ;
386+ expect ( tail . config ) . toEqual ( { headers : [ [ 'Accept' , 'json' ] , [ 'Authorization' ] ] } ) ;
387+ } ) ;
388+
303389 it ( 'the input is never mutated' , ( ) => {
304390 const before = JSON . stringify ( STORED ) ;
305391 redactDatasourceConfig ( DRIVER , STORED ) ;
0 commit comments