From 8c0bd9124f1575b923097a753a2cd1c6461f6a51 Mon Sep 17 00:00:00 2001 From: Rebecca Hum Date: Tue, 18 Jun 2024 17:44:28 -0600 Subject: [PATCH] BYOR: Add vip app deploy validate --- npm-shrinkwrap.json | 131 +++++++++++++++++++++ package.json | 2 + src/bin/vip-app-deploy.ts | 1 + src/lib/custom-deploy/custom-deploy.ts | 153 +++++++++++++++++++++++++ 4 files changed, 287 insertions(+) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9c4c30b035..e90a7091ed 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -24,6 +24,7 @@ "debug": "4.3.5", "ejs": "^3.1.8", "enquirer": "2.4.1", + "extract-zip": "^2.0.1", "fetch-retry": "^6.0.0", "graphql": "15.5.1", "graphql-tag": "2.12.6", @@ -50,6 +51,7 @@ "vip": "dist/bin/vip.js", "vip-app": "dist/bin/vip-app.js", "vip-app-deploy": "dist/bin/vip-app-deploy.js", + "vip-app-deploy-validate": "dist/bin/vip-app-deploy-validate.js", "vip-app-list": "dist/bin/vip-app-list.js", "vip-backup": "dist/bin/vip-backup.js", "vip-backup-db": "dist/bin/vip-backup-db.js", @@ -3914,6 +3916,15 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/zen-observable": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", @@ -5033,6 +5044,14 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -7144,6 +7163,39 @@ "node": ">=4" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eyes": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", @@ -7209,6 +7261,14 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fetch-retry": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz", @@ -10820,6 +10880,11 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -13419,6 +13484,15 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -16251,6 +16325,15 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@types/zen-observable": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", @@ -17020,6 +17103,11 @@ "ieee754": "^1.1.13" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" + }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -18554,6 +18642,27 @@ "tmp": "^0.0.33" } }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + } + } + }, "eyes": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", @@ -18613,6 +18722,14 @@ "bser": "2.1.1" } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "requires": { + "pend": "~1.2.0" + } + }, "fetch-retry": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz", @@ -21241,6 +21358,11 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -23103,6 +23225,15 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 8612da439c..b659b5b47a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "vip-app": "dist/bin/vip-app.js", "vip-app-list": "dist/bin/vip-app-list.js", "vip-app-deploy": "dist/bin/vip-app-deploy.js", + "vip-app-deploy-validate": "dist/bin/vip-app-deploy-validate.js", "vip-backup": "dist/bin/vip-backup.js", "vip-backup-db": "dist/bin/vip-backup-db.js", "vip-cache": "dist/bin/vip-cache.js", @@ -153,6 +154,7 @@ "debug": "4.3.5", "ejs": "^3.1.8", "enquirer": "2.4.1", + "extract-zip": "^2.0.1", "fetch-retry": "^6.0.0", "graphql": "15.5.1", "graphql-tag": "2.12.6", diff --git a/src/bin/vip-app-deploy.ts b/src/bin/vip-app-deploy.ts index f2fb4836e6..39e712525d 100755 --- a/src/bin/vip-app-deploy.ts +++ b/src/bin/vip-app-deploy.ts @@ -268,6 +268,7 @@ const examples = [ void command( { requiredArgs: 1, } ) + .command( 'validate', 'Validate a file before deploying in Custom Deployments' ) .examples( examples ) .option( 'message', 'Custom message for deploy' ) .option( 'skip-confirmation', 'Skip confirmation prompt' ) diff --git a/src/lib/custom-deploy/custom-deploy.ts b/src/lib/custom-deploy/custom-deploy.ts index 595b2b155b..1009972018 100644 --- a/src/lib/custom-deploy/custom-deploy.ts +++ b/src/lib/custom-deploy/custom-deploy.ts @@ -1,5 +1,10 @@ import fs from 'fs'; import gql from 'graphql-tag'; +import zlib from 'zlib'; +import { join, extname } from 'path'; +import extract from 'extract-zip'; +import { exec } from 'child_process'; +import debugLib from 'debug'; import API from '../../lib/api'; import * as exit from '../../lib/cli/exit'; @@ -7,6 +12,9 @@ import { checkFileAccess, getFileSize, isFile, FileMeta } from '../../lib/client import { GB_IN_BYTES } from '../../lib/constants/file-size'; import { trackEventWithEnv } from '../../lib/tracker'; import { validateDeployFileExt, validateFilename } from '../../lib/validations/custom-deploy'; +import { makeTempDir } from '../../lib/utils'; + +const debug = debugLib( '@automattic/vip:bin:lib-custom-deploy' ); const DEPLOY_MAX_FILE_SIZE = 4 * GB_IN_BYTES; const WPVIP_DEPLOY_TOKEN = process.env.WPVIP_DEPLOY_TOKEN; @@ -129,3 +137,148 @@ export async function validateFile( appId: number, envId: number, fileMeta: File ); } } + +/** + * Extracts the compressed file to a temporary directory + * + * @param {string} file The compressed file + * @returns {string} The path to the temporary directory + */ +export async function extractFile( file: string ): Promise< string > { + const tempDir = makeTempDir( 'custom-deploy' ); + const ext = extname( file ); + + if ( ext === '.zip' ) { + try { + await extract( file, { dir: tempDir } ); + } catch ( error ) { + exit.withError( `Error extracting file: ${ error }` ); + } + } else { + // .tar.gz, .tgz files + try { + await new Promise< void >( ( resolve, reject ) => { + const tarProcess = exec( `tar -xz -C ${ tempDir }`, ( err, stdout, stderr ) => { + if ( err ) { + reject( err ); + } else { + console.log( `Decompressed and untarred to: ${ tempDir }` ); + resolve(); + } + } ); + + if ( tarProcess.stdin ) { + fs.createReadStream( file ) + .pipe( zlib.createGunzip() ) + .pipe( tarProcess.stdin ) + .on( 'error', reject ); + } else { + reject( new Error( 'Failed to create tar process stdin stream' ) ); + } + } ); + } catch ( error ) { + exit.withError( `Error decompressing and extracting file: ${ error }` ); + } + } + + return tempDir; +} + +/** + * Recursively remove unwanted items from the directory + * + * @param {string} directory The directory to clean + * @param {string[]} files The files in the directory + */ +function rmUnneededItems( directory: string, files: string[] ) { + const itemsToRm = [ + '__MACOSX', + '.DS_Store', + 'Thumbs.db', + 'desktop.ini', + '.Spotlight-V100', + '.Trashes', + '.fseventsd', + '.apdisk', + '.TemporaryItems', + ]; + + for ( const file of files ) { + const filePath = `${ directory }/${ file }`; + + if ( itemsToRm.includes( file ) ) { + debug( `Removing unwanted item: ${ filePath }` ); + fs.rmSync( filePath, { recursive: true, force: true } ); + } else { + const stats = fs.statSync( filePath ); + if ( stats.isDirectory() ) { + const nestedFiles = fs.readdirSync( filePath ); + rmUnneededItems( filePath, nestedFiles ); + } + } + } +} + +/** + * Recursively checks if the directory contains any dangerous filenames or symlinks. + * + * @param {string} directory The directory to validate + * @param {string} items The items in the directory + */ +function validateDirContentsRecursively( directory: string, items: string[] ) { + for ( const itemName of items ) { + const itemPath = join( directory, itemName ); + + if ( + /[!/:*?"<>|']/.test( itemName ) || + ( itemName.startsWith( '.' ) && itemName.length > 1 ) + ) { + exit.withError( `Error: Dangerous filename detected: ${ itemName }` ); + } + + const stats = fs.lstatSync( itemPath ); + if ( stats.isSymbolicLink() ) { + exit.withError( `Error: Symlink detected: ${ itemName }` ); + } + + // If the item is a directory, recursively validate its contents + if ( stats.isDirectory() ) { + // Skip node_modules and its .bin subdirectory + if ( itemPath.includes( 'node_modules' ) && itemPath.includes( '.bin' ) ) { + continue; + } + + const recursiveItems = fs.readdirSync( itemPath ); + validateDirContentsRecursively( itemPath, recursiveItems ); + } + } +} + +function validateRootFolder( directory: string, folder: string ) { + const path = join( directory, folder ); + const stats = fs.statSync( path ); + if ( ! stats.isDirectory() ) { + exit.withError( 'The compressed file should have a root folder.' ); + } + + const rootFolderContents = fs.readdirSync( path ); + if ( ! rootFolderContents.includes( 'themes' ) ) { + exit.withError( "The compressed file should contain a 'themes' folder." ); + } +} + +export function validateDirectory( directory: string ) { + const files = fs.readdirSync( directory ); + + rmUnneededItems( directory, files ); + + if ( files.length === 0 ) { + exit.withError( 'The compressed file should contain at least one folder.' ); + } else if ( files.length !== 1 ) { + exit.withError( 'The compressed file should contain only one folder.' ); + } + + validateDirContentsRecursively( directory, files ); + + validateRootFolder( directory, files[ 0 ] ); +}