From 5273c82d378e15223cf095003048fdfc04951ad5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 29 Aug 2026 15:26:22 -0400 Subject: [PATCH] CLI: redact admin passwords from default list JSON Keep inventory machine output non-secret and send credential retrieval through `studio config get admin-password`. --- apps/cli/README.md | 3 + apps/cli/commands/site/list.ts | 6 +- apps/cli/commands/site/status.ts | 20 +-- apps/cli/commands/site/tests/list.test.ts | 123 +++++++++++++++--- apps/cli/commands/site/tests/status.test.ts | 24 +++- apps/cli/lib/site-secret-fields.ts | 11 ++ apps/cli/lib/tests/site-secret-fields.test.ts | 35 +++++ skills/studio-cli/SKILL.md | 16 ++- 8 files changed, 200 insertions(+), 38 deletions(-) create mode 100644 apps/cli/lib/site-secret-fields.ts create mode 100644 apps/cli/lib/tests/site-secret-fields.test.ts diff --git a/apps/cli/README.md b/apps/cli/README.md index af28ce96e5..b774edfd4b 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -64,8 +64,11 @@ The Studio CLI integrates with Studio and uses the same list of sites. Similarly ```bash studio list +studio list --format json ``` +JSON inventory output includes site identity, path, status, runtime, version, and URL fields. It does not include admin passwords or other secrets. To read one site's admin password, run `studio config get admin-password --path ` and do not log the value. + To start and stop sites, run these commands: ```bash diff --git a/apps/cli/commands/site/list.ts b/apps/cli/commands/site/list.ts index 35ba9ebb09..603cc4be62 100644 --- a/apps/cli/commands/site/list.ts +++ b/apps/cli/commands/site/list.ts @@ -6,6 +6,7 @@ import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; import { getSiteUrl } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { getLiveSiteOperation } from 'cli/lib/site-operations'; +import { omitSiteSecretFields } from 'cli/lib/site-secret-fields'; import { isSiteRunning } from 'cli/lib/site-utils'; import { getColumnWidths, getPrettyPath } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; @@ -86,9 +87,10 @@ function displaySiteList( console.log( table.toString() ); } else { - const json = JSON.stringify( data.jsonEntries ); + const json = JSON.stringify( data.jsonEntries.map( omitSiteSecretFields ) ); console.log( json ); - logger.reportKeyValuePair( 'sites', json ); + // IPC consumers (desktop/local) still receive the full record, including credentials. + logger.reportKeyValuePair( 'sites', JSON.stringify( data.jsonEntries ) ); } } diff --git a/apps/cli/commands/site/status.ts b/apps/cli/commands/site/status.ts index 8f11fbc8fb..942f20a6cd 100644 --- a/apps/cli/commands/site/status.ts +++ b/apps/cli/commands/site/status.ts @@ -53,6 +53,7 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) value: string | undefined; type?: string; hidden?: boolean; + secret?: boolean; }[] = [ { key: __( 'Site URL' ), @@ -83,6 +84,7 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) key: __( 'Admin password' ), jsonKey: 'adminPassword', value: site.adminPassword ? decodePassword( site.adminPassword ) : undefined, + secret: true, }, { key: __( 'Admin email' ), jsonKey: 'adminEmail', value: site.adminEmail }, ].filter( ( { value, hidden } ) => value && ! hidden ); @@ -104,14 +106,16 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) console.table( table.toString() ); } else { const logData = Object.fromEntries( - siteData.flatMap( ( { jsonKey, value } ) => - jsonKey === 'status' - ? [ - [ jsonKey, value ], - [ 'isOnline', isOnline ], - ] - : [ [ jsonKey, value ] ] - ) + siteData + .filter( ( { secret } ) => ! secret ) + .flatMap( ( { jsonKey, value } ) => + jsonKey === 'status' + ? [ + [ jsonKey, value ], + [ 'isOnline', isOnline ], + ] + : [ [ jsonKey, value ] ] + ) ); console.log( JSON.stringify( logData, null, 2 ) ); diff --git a/apps/cli/commands/site/tests/list.test.ts b/apps/cli/commands/site/tests/list.test.ts index c8ad19cd20..ae06b4b3d9 100644 --- a/apps/cli/commands/site/tests/list.test.ts +++ b/apps/cli/commands/site/tests/list.test.ts @@ -1,9 +1,27 @@ import { vi } from 'vitest'; import { readCliConfig } from 'cli/lib/cli-config/core'; import { connectToDaemon, disconnectFromDaemon, listProcesses } from 'cli/lib/daemon-client'; +import { SITE_SECRET_FIELD_KEYS } from 'cli/lib/site-secret-fields'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { mockReportKeyValuePair } from 'cli/tests/test-utils'; import { runCommand } from '../list'; + +const SECRET_KEY_PATTERN = /password|secret|tlsKey|tlsCert/i; + +function collectKeys( value: unknown, keys = new Set< string >() ): Set< string > { + if ( Array.isArray( value ) ) { + for ( const item of value ) { + collectKeys( item, keys ); + } + } else if ( value && typeof value === 'object' ) { + for ( const [ key, nested ] of Object.entries( value ) ) { + keys.add( key ); + collectKeys( nested, keys ); + } + } + return keys; +} + vi.mock( 'cli/lib/cli-config/core', async () => { const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { @@ -93,33 +111,96 @@ describe( 'CLI: studio site list', () => { } ); it( 'should list sites with json format', async () => { + const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} ); + await runCommand( 'json' ); + const publicSites = [ + { + id: 'site-1', + name: 'Test Site 1', + path: '/path/to/site1', + port: 8080, + phpVersion: '8.0', + url: 'http://localhost:8080', + running: false, + }, + { + id: 'site-2', + name: 'Test Site 2', + path: '/path/to/site2', + port: 8081, + phpVersion: '8.0', + customDomain: 'my-site.wp.local', + url: 'http://my-site.wp.local', + running: false, + }, + ]; + expect( consoleSpy ).toHaveBeenCalledWith( JSON.stringify( publicSites ) ); expect( mockReportKeyValuePair ).toHaveBeenCalledWith( 'sites', - JSON.stringify( [ - { - id: 'site-1', - name: 'Test Site 1', - path: '/path/to/site1', - port: 8080, - phpVersion: '8.0', - url: 'http://localhost:8080', - running: false, - }, - { - id: 'site-2', - name: 'Test Site 2', - path: '/path/to/site2', - port: 8081, - phpVersion: '8.0', - customDomain: 'my-site.wp.local', - url: 'http://my-site.wp.local', - running: false, - }, - ] ) + JSON.stringify( publicSites ) ); expect( disconnectFromDaemon ).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + } ); + + it( 'omits secret credential fields from default list JSON', async () => { + const plaintextPassword = 'super-secret-admin-password'; + const encodedPassword = btoa( plaintextPassword ); + vi.mocked( readCliConfig ).mockResolvedValue( { + ...testCliConfig, + sites: [ + { + ...testCliConfig.sites[ 0 ], + adminPassword: encodedPassword, + runtime: 'native-php', + tlsKey: 'TLS_PRIVATE_KEY_MATERIAL', + tlsCert: 'TLS_CERT_MATERIAL', + }, + ], + } as Awaited< ReturnType< typeof readCliConfig > > ); + + const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} ); + + await runCommand( 'json' ); + + expect( consoleSpy ).toHaveBeenCalledTimes( 1 ); + const stdout = String( consoleSpy.mock.calls[ 0 ][ 0 ] ); + const parsed = JSON.parse( stdout ) as Array< Record< string, unknown > >; + + expect( parsed ).toHaveLength( 1 ); + expect( parsed[ 0 ] ).toMatchObject( { + id: 'site-1', + name: 'Test Site 1', + path: '/path/to/site1', + port: 8080, + phpVersion: '8.0', + runtime: 'native-php', + url: 'http://localhost:8080', + running: false, + } ); + + for ( const key of collectKeys( parsed ) ) { + expect( key ).not.toMatch( SECRET_KEY_PATTERN ); + } + for ( const key of SITE_SECRET_FIELD_KEYS ) { + expect( parsed[ 0 ] ).not.toHaveProperty( key ); + } + expect( stdout ).not.toContain( plaintextPassword ); + expect( stdout ).not.toContain( encodedPassword ); + expect( stdout ).not.toContain( 'TLS_PRIVATE_KEY_MATERIAL' ); + expect( stdout ).not.toContain( 'TLS_CERT_MATERIAL' ); + + const [ , ipcJson ] = mockReportKeyValuePair.mock.calls[ 0 ]; + const ipcSites = JSON.parse( ipcJson ) as Array< Record< string, unknown > >; + expect( ipcSites[ 0 ] ).toMatchObject( { + id: 'site-1', + adminPassword: encodedPassword, + } ); + + consoleSpy.mockRestore(); } ); // Both front ends disable a site's actions on what this reports, so an diff --git a/apps/cli/commands/site/tests/status.test.ts b/apps/cli/commands/site/tests/status.test.ts index 995eeed3c7..af9f8391af 100644 --- a/apps/cli/commands/site/tests/status.test.ts +++ b/apps/cli/commands/site/tests/status.test.ts @@ -89,7 +89,6 @@ describe( 'CLI: studio site status', () => { wpVersion: '6.4', xdebug: 'Disabled', adminUsername: 'admin', - adminPassword: 'password123', }, null, 2 @@ -99,6 +98,28 @@ describe( 'CLI: studio site status', () => { consoleSpy.mockRestore(); } ); + it( 'omits secret credential fields from status JSON', async () => { + const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} ); + + await runCommand( '/path/to/site', 'json' ); + + const stdout = String( consoleSpy.mock.calls[ 0 ][ 0 ] ); + const parsed = JSON.parse( stdout ) as Record< string, unknown >; + + expect( parsed ).not.toHaveProperty( 'adminPassword' ); + expect( stdout ).not.toContain( 'password123' ); + expect( parsed ).toMatchObject( { + siteUrl: 'http://localhost:8080/', + sitePath: '/path/to/site', + phpVersion: '8.0', + runtime: 'Native', + wpVersion: '6.4', + adminUsername: 'admin', + } ); + + consoleSpy.mockRestore(); + } ); + it( 'should show online status when server is running', async () => { vi.mocked( isServerRunning ).mockResolvedValue( { name: 'test-site', @@ -126,7 +147,6 @@ describe( 'CLI: studio site status', () => { wpVersion: '6.4', xdebug: 'Disabled', adminUsername: 'admin', - adminPassword: 'password123', }, null, 2 diff --git a/apps/cli/lib/site-secret-fields.ts b/apps/cli/lib/site-secret-fields.ts new file mode 100644 index 0000000000..79b379a133 --- /dev/null +++ b/apps/cli/lib/site-secret-fields.ts @@ -0,0 +1,11 @@ +export const SITE_SECRET_FIELD_KEYS = [ 'adminPassword', 'tlsKey', 'tlsCert' ] as const; + +export type SiteSecretField = ( typeof SITE_SECRET_FIELD_KEYS )[ number ]; + +export function omitSiteSecretFields< T extends object >( record: T ): Omit< T, SiteSecretField > { + const publicRecord = { ...record } as T & Partial< Record< SiteSecretField, unknown > >; + for ( const key of SITE_SECRET_FIELD_KEYS ) { + delete publicRecord[ key ]; + } + return publicRecord; +} diff --git a/apps/cli/lib/tests/site-secret-fields.test.ts b/apps/cli/lib/tests/site-secret-fields.test.ts new file mode 100644 index 0000000000..e7d50b57f5 --- /dev/null +++ b/apps/cli/lib/tests/site-secret-fields.test.ts @@ -0,0 +1,35 @@ +import { omitSiteSecretFields, SITE_SECRET_FIELD_KEYS } from '../site-secret-fields'; + +describe( 'omitSiteSecretFields', () => { + it( 'drops known secret keys and keeps inventory fields', () => { + const publicRecord = omitSiteSecretFields( { + id: 'site-1', + name: 'Test Site', + path: '/path/to/site', + port: 8881, + phpVersion: '8.4', + runtime: 'native-php', + url: 'http://localhost:8881', + running: true, + adminUsername: 'admin', + adminPassword: 'encoded-secret', + tlsKey: 'private-key', + tlsCert: 'certificate', + } ); + + expect( publicRecord ).toEqual( { + id: 'site-1', + name: 'Test Site', + path: '/path/to/site', + port: 8881, + phpVersion: '8.4', + runtime: 'native-php', + url: 'http://localhost:8881', + running: true, + adminUsername: 'admin', + } ); + for ( const key of SITE_SECRET_FIELD_KEYS ) { + expect( publicRecord ).not.toHaveProperty( key ); + } + } ); +} ); diff --git a/skills/studio-cli/SKILL.md b/skills/studio-cli/SKILL.md index 31c5d0aa19..c2d18732f5 100644 --- a/skills/studio-cli/SKILL.md +++ b/skills/studio-cli/SKILL.md @@ -17,8 +17,8 @@ The `studio` command manages local WordPress sites. ```bash studio create # Create a new site -studio list # List all sites (--format table|json) -studio status # Show site details (--format table|json) +studio list # List all sites (--format table|json). JSON is inventory-only and never includes passwords or TLS material. +studio status # Show site details (--format table|json). JSON omits admin passwords; use `config get admin-password` for credentials. studio start # Start a site studio stop # Stop a site (--all to stop all) studio delete # Delete a site (--files to trash site files) @@ -43,11 +43,17 @@ Without flags in a TTY, the CLI prompts interactively for name, path, WP/PHP ver ### Checking site details -`studio status` shows site URL, auto-login URL, admin credentials, PHP/WP versions, Xdebug status, and online/offline status. Prefer this over individual `wp-cli` calls when you need general site info. +`studio status` shows site URL, auto-login URL, PHP/WP versions, Xdebug status, and online/offline status. Prefer this over individual `wp-cli` calls when you need general site info. Table output may include the admin password for the selected site; JSON never does. ```bash studio status --path ~/Studio/my-site # Table output -studio status --path ~/Studio/my-site --format json # JSON output (fields: siteUrl, autoLoginUrl, sitePath, status, phpVersion, wpVersion, xdebug, adminUsername, adminPassword, adminEmail) +studio status --path ~/Studio/my-site --format json # JSON output (fields: siteUrl, autoLoginUrl, sitePath, status, phpVersion, runtime, fileAccess, wpVersion, xdebug, adminUsername, adminEmail) +``` + +Retrieve one site's admin password with an explicit, auditable command. Do not log, pipe into transcripts, or cache the value: + +```bash +studio config get admin-password --path ~/Studio/my-site ``` ### Reading and changing configuration @@ -153,7 +159,7 @@ studio wp --path ~/Studio/my-site user list ## Tips - Use `--path` to target a specific site directory, or `cd` into the site folder first. -- Use `--format json` on `list`, `status`, `config get`, and `preview list` for machine-readable output. For a single config value, `studio config get ` prints it raw (no parsing needed). +- Use `--format json` on `list`, `status`, `config get`, and `preview list` for machine-readable output. `list --format json` and `status --format json` omit passwords and other secrets. For a single config value, `studio config get ` prints it raw (no parsing needed). Never log `studio config get admin-password`. - Run `studio --help` to see all options for any command. - Custom domains require hosts file changes (may need elevated permissions on macOS/Linux). - HTTPS uses self-signed certificates stored in platform-specific locations.