From 3496bf136402a1ba9ea9ed7ff857c864dd36fe14 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 19 Dec 2025 10:58:02 -0500 Subject: [PATCH 1/2] Use @socketsecurity/socket-patch for patch command - Replace inline patch implementation with @socketsecurity/socket-patch@1.0.0 - Use runPatch() from socket-patch/run for programmatic invocation - Remove deleted handle-patch.mts, manifest-schema.mts, output-patch-result.mts - Add SOCKET_PATCH_PROXY_URL environment variable support - Forward socket-cli environment to socket-patch options --- package.json | 1 + src/commands/patch/cmd-patch.mts | 143 ++----- src/commands/patch/cmd-patch.test.mts | 361 ---------------- src/commands/patch/handle-patch.mts | 465 --------------------- src/commands/patch/manifest-schema.mts | 34 -- src/commands/patch/output-patch-result.mts | 46 -- src/constants.mts | 3 + 7 files changed, 35 insertions(+), 1018 deletions(-) delete mode 100644 src/commands/patch/cmd-patch.test.mts delete mode 100644 src/commands/patch/handle-patch.mts delete mode 100644 src/commands/patch/manifest-schema.mts delete mode 100644 src/commands/patch/output-patch-result.mts diff --git a/package.json b/package.json index 89cdfe68c..fddd3aa47 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "@socketsecurity/config": "3.0.1", "@socketsecurity/registry": "1.1.17", "@socketsecurity/sdk": "1.4.95", + "@socketsecurity/socket-patch": "1.0.0", "@types/blessed": "0.1.25", "@types/cmd-shim": "5.0.2", "@types/js-yaml": "4.0.9", diff --git a/src/commands/patch/cmd-patch.mts b/src/commands/patch/cmd-patch.mts index d00a1f8f8..2b889c66f 100644 --- a/src/commands/patch/cmd-patch.mts +++ b/src/commands/patch/cmd-patch.mts @@ -1,34 +1,13 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' +import constants from '../../constants.mts' +import { runPatch } from '@socketsecurity/socket-patch/run' -import { arrayUnique } from '@socketsecurity/registry/lib/arrays' - -import { handlePatch } from './handle-patch.mts' -import constants, { DOT_SOCKET_DIR, MANIFEST_JSON } from '../../constants.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { checkCommandInput } from '../../utils/check-input.mts' -import { cmdFlagValueToArray } from '../../utils/cmd.mts' -import { InputError } from '../../utils/errors.mts' -import { getOutputKind } from '../../utils/get-output-kind.mts' -import { meowOrExit } from '../../utils/meow-with-subcommands.mts' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output-formatting.mts' -import { getPurlObject } from '../../utils/purl.mts' - -import type { - CliCommandConfig, - CliCommandContext, -} from '../../utils/meow-with-subcommands.mts' -import type { PurlObject } from '../../utils/purl.mts' -import type { PackageURL } from '@socketregistry/packageurl-js' +import type { CliCommandContext } from '../../utils/meow-with-subcommands.mts' export const CMD_NAME = 'patch' -const description = 'Apply CVE patches to dependencies' +const description = 'Manage CVE patches for dependencies' -const hidden = true +const hidden = false export const cmdPatch = { description, @@ -38,100 +17,40 @@ export const cmdPatch = { async function run( argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, + _importMeta: ImportMeta, + _context: CliCommandContext, ): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden, - flags: { - ...commonFlags, - ...outputFlags, - purl: { - type: 'string', - default: [], - description: - 'Specify purls to patch, as either a comma separated value or as multiple flags', - isMultiple: true, - shortFlag: 'p', - }, - }, - help: (command, config) => ` - Usage - $ ${command} [options] [CWD=.] + const { ENV } = constants - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} + // Map socket-cli environment to socket-patch options. + // Only include properties with defined values (exactOptionalPropertyTypes). + const options: Parameters[1] = {} - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} --package lodash - $ ${command} ./path/to/project --package lodash,react - `, + // Strip /v0/ suffix from API URL if present. + const apiUrl = ENV.SOCKET_CLI_API_BASE_URL?.replace(/\/v0\/?$/, '') + if (apiUrl) { + options.apiUrl = apiUrl } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { dryRun, json, markdown } = cli.flags as { - dryRun: boolean - json: boolean - markdown: boolean + if (ENV.SOCKET_CLI_API_TOKEN) { + options.apiToken = ENV.SOCKET_CLI_API_TOKEN } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return + if (ENV.SOCKET_CLI_ORG_SLUG) { + options.orgSlug = ENV.SOCKET_CLI_ORG_SLUG } - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found in current directory`, - ) + if (ENV.SOCKET_PATCH_PROXY_URL) { + options.patchProxyUrl = ENV.SOCKET_PATCH_PROXY_URL } - - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - if (!existsSync(manifestPath)) { - throw new InputError( - `No ${MANIFEST_JSON} found in ${DOT_SOCKET_DIR} directory`, - ) + if (ENV.SOCKET_CLI_API_PROXY) { + options.httpProxy = ENV.SOCKET_CLI_API_PROXY + } + if (ENV.SOCKET_CLI_DEBUG) { + options.debug = ENV.SOCKET_CLI_DEBUG } - const { spinner } = constants - - const purlObjs = arrayUnique(cmdFlagValueToArray(cli.flags['purl'])) - .map(p => getPurlObject(p, { throws: false })) - .filter(Boolean) as Array> + // Forward all arguments to socket-patch. + const exitCode = await runPatch([...argv], options) - await handlePatch({ - cwd, - dryRun, - outputKind, - purlObjs, - spinner, - }) + if (exitCode !== 0) { + process.exitCode = exitCode + } } diff --git a/src/commands/patch/cmd-patch.test.mts b/src/commands/patch/cmd-patch.test.mts deleted file mode 100644 index 3f59cc791..000000000 --- a/src/commands/patch/cmd-patch.test.mts +++ /dev/null @@ -1,361 +0,0 @@ -import path from 'node:path' - -import trash from 'trash' -import { afterEach, describe, expect } from 'vitest' - -import { cmdit, spawnSocketCli, testPath } from '../../../test/utils.mts' -import constants, { FLAG_CONFIG, FLAG_HELP } from '../../constants.mts' - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -async function cleanupNodeModules() { - // Clean up node_modules from all package manager directories. - await trash(path.join(pnpmFixtureDir, 'node_modules')) - await trash(path.join(fixtureBaseDir, 'npm/node_modules')) - await trash(path.join(fixtureBaseDir, 'yarn/node_modules')) -} - -describe('socket patch', async () => { - const { binCliPath } = constants - - afterEach(async () => { - await cleanupNodeModules() - }) - - cmdit( - ['patch', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Apply CVE patches to dependencies') - expect(stderr).toContain('`socket patch`') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - path.join(fixtureBaseDir, 'nonexistent'), - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show error when no .socket directory found', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('No .socket directory found') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - ['patch', '.', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should scan for available patches when no node_modules found', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0 when no packages to patch').toBe(0) - }, - ) - - cmdit( - ['patch', '.', '--json', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should output results in JSON format', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', '.', '--markdown', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should output results in markdown format', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--json', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should fail when both json and markdown flags are used', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output).toContain('json and markdown flags cannot be both set') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '-p', - 'pkg:npm/on-headers@1.0.2', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should accept short flag -p for purl', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - describe('comprehensive patch tests', () => { - cmdit( - [ - 'patch', - '.', - '--purl', - 'pkg:npm/on-headers@1.0.2', - '--json', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle specific PURL with JSON output', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(stdout).toBeDefined() - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--purl', - 'pkg:npm/on-headers@1.0.2', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle specific PURL with markdown output', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(stdout).toBeDefined() - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', '.', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should scan all packages in manifest when no specific PURL given', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(stdout).toBeDefined() - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--purl', - 'pkg:npm/nonexistent@1.0.0', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle non-existent packages gracefully', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0 for non-existent packages').toBe( - 0, - ) - }, - ) - }) - - describe('error handling and usability tests', () => { - cmdit( - ['patch', '/nonexistent/path', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should show clear error for non-existent directory', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('No .socket directory found') - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - ['patch', FLAG_CONFIG, '{}'], - 'should show clear error when API token is missing', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toMatch(/api token|authentication|token/i) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--purl', - 'invalid-purl-format', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle invalid PURL formats gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(code, 'should exit with code 0 for invalid PURL').toBe(0) - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--purl', - 'pkg:npm/nonexistent-package@999.999.999', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle PURLs for packages not in manifest', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect( - code, - 'should exit with code 0 for packages not in manifest', - ).toBe(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--purl', - 'pkg:npm/@scoped/package@1.0.0', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle scoped package PURLs correctly', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', FLAG_HELP, '--purl', 'pkg:npm/test@1.0.0', FLAG_CONFIG, '{}'], - 'should prioritize help over other flags', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Apply CVE patches to dependencies') - expect(code).toBe(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--purl', - 'pkg:npm/on-headers@1.0.2', - '--purl', - 'pkg:npm/another-package@2.0.0', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle multiple PURL flags', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', '.', FLAG_CONFIG, '{"apiToken":"invalid-format-token"}'], - 'should handle invalid API tokens gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0 with invalid token').toBe(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--purl', - 'pkg:pypi/python-package@1.0.0', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle non-npm ecosystem PURLs appropriately', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - '.', - '--purl', - 'pkg:npm/test@', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle PURLs with missing versions', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(code, 'should exit with code 0 for malformed PURL').toBe(0) - expect(output.length).toBeGreaterThan(0) - }, - ) - }) -}) diff --git a/src/commands/patch/handle-patch.mts b/src/commands/patch/handle-patch.mts deleted file mode 100644 index f5625160a..000000000 --- a/src/commands/patch/handle-patch.mts +++ /dev/null @@ -1,465 +0,0 @@ -import crypto from 'node:crypto' -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import fastGlob from 'fast-glob' - -import { joinAnd } from '@socketsecurity/registry/lib/arrays' -import { debugDir } from '@socketsecurity/registry/lib/debug' -import { readDirNames } from '@socketsecurity/registry/lib/fs' -import { logger } from '@socketsecurity/registry/lib/logger' -import { readPackageJson } from '@socketsecurity/registry/lib/packages' -import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' -import { pluralize } from '@socketsecurity/registry/lib/words' - -import { PatchManifestSchema } from './manifest-schema.mts' -import { outputPatchResult } from './output-patch-result.mts' -import { - DOT_SOCKET_DIR, - MANIFEST_JSON, - NODE_MODULES, - NPM, - UTF8, -} from '../../constants.mts' -import { getErrorCause } from '../../utils/errors.mts' -import { findUp } from '../../utils/fs.mts' -import { getPurlObject, normalizePurl } from '../../utils/purl.mts' - -import type { PatchRecord } from './manifest-schema.mts' -import type { CResult, OutputKind } from '../../types.mts' -import type { PackageURL } from '@socketregistry/packageurl-js' -import type { Spinner } from '@socketsecurity/registry/lib/spinner' - -type PatchEntry = { - key: string - patch: PatchRecord - purl: string - purlObj: PackageURL -} - -type PatchFileInfo = { - beforeHash: string - afterHash: string -} - -type ApplyNpmPatchesOptions = { - cwd?: string | undefined - dryRun?: boolean | undefined - purlObjs?: PackageURL[] | undefined - spinner?: Spinner | undefined -} - -type ApplyNpmPatchesResult = { - passed: string[] - failed: string[] -} - -async function applyNpmPatches( - socketDir: string, - patches: PatchEntry[], - options?: ApplyNpmPatchesOptions | undefined, -): Promise { - const { - cwd = process.cwd(), - dryRun = false, - purlObjs, - spinner, - } = { __proto__: null, ...options } as ApplyNpmPatchesOptions - - const wasSpinning = !!spinner?.isSpinning - - spinner?.start() - - const patchLookup = new Map() - for (const patchInfo of patches) { - patchLookup.set(patchInfo.purl, patchInfo) - } - - const nmPaths = await findNodeModulesPaths(cwd) - - spinner?.stop() - - logger.log( - `Found ${nmPaths.length} ${NODE_MODULES} ${pluralize('folder', nmPaths.length)}`, - ) - - logger.group('') - - spinner?.start() - - const result: ApplyNpmPatchesResult = { - passed: [], - failed: [], - } - - for (const nmPath of nmPaths) { - // eslint-disable-next-line no-await-in-loop - const dirNames = await readDirNames(nmPath) - for (const dirName of dirNames) { - const isScoped = dirName.startsWith('@') - const pkgPath = path.join(nmPath, dirName) - const pkgSubNames = isScoped - ? // eslint-disable-next-line no-await-in-loop - await readDirNames(pkgPath) - : [dirName] - - for (const pkgSubName of pkgSubNames) { - const dirFullName = isScoped ? `${dirName}/${pkgSubName}` : pkgSubName - const pkgPath = path.join(nmPath, dirFullName) - // eslint-disable-next-line no-await-in-loop - const pkgJson = await readPackageJson(pkgPath, { throws: false }) - if ( - !isNonEmptyString(pkgJson?.name) || - !isNonEmptyString(pkgJson?.version) - ) { - continue - } - - const purl = `pkg:npm/${pkgJson.name}@${pkgJson.version}` - const purlObj = getPurlObject(purl, { throws: false }) - if (!purlObj) { - continue - } - - // Skip if specific packages requested and this isn't one of them - if ( - purlObjs?.length && - purlObjs.findIndex( - p => - p.type === NPM && - p.namespace === purlObj.namespace && - p.name === purlObj.name, - ) === -1 - ) { - continue - } - - const patchInfo = patchLookup.get(purl) - if (!patchInfo) { - continue - } - - spinner?.stop() - - logger.log( - `Found match: ${pkgJson.name}@${pkgJson.version} at ${pkgPath}`, - ) - logger.log(`Patch key: ${patchInfo.key}`) - logger.group(`Processing files:`) - - spinner?.start() - - let passed = true - - for (const { 0: fileName, 1: fileInfo } of Object.entries( - patchInfo.patch.files, - )) { - // eslint-disable-next-line no-await-in-loop - const filePatchPassed = await processFilePatch( - pkgPath, - fileName, - fileInfo, - socketDir, - { - dryRun, - spinner, - }, - ) - if (!filePatchPassed) { - passed = false - } - } - - logger.groupEnd() - - if (passed) { - result.passed.push(purl) - } else { - result.failed.push(purl) - } - } - } - } - - spinner?.stop() - - logger.groupEnd() - - if (wasSpinning) { - spinner.start() - } - return result -} - -/** - * Compute SHA256 hash of file contents. - */ -async function computeSHA256(filepath: string): Promise> { - try { - const content = await fs.readFile(filepath) - const hash = crypto.createHash('sha256') - hash.update(content) - return { - ok: true, - data: hash.digest('hex'), - } - } catch (e) { - return { - ok: false, - message: 'Failed to compute file hash', - cause: `Unable to read file ${filepath}: ${getErrorCause(e)}`, - } - } -} - -async function findNodeModulesPaths(cwd: string): Promise { - const rootNmPath = await findUp(NODE_MODULES, { cwd, onlyDirectories: true }) - if (!rootNmPath) { - return [] - } - return await fastGlob.glob([`**/${NODE_MODULES}`], { - absolute: true, - cwd: path.dirname(rootNmPath), - dot: true, - followSymbolicLinks: false, - onlyDirectories: true, - }) -} - -type ProcessFilePatchOptions = { - dryRun?: boolean | undefined - spinner?: Spinner | undefined -} - -async function processFilePatch( - pkgPath: string, - fileName: string, - fileInfo: PatchFileInfo, - socketDir: string, - options?: ProcessFilePatchOptions | undefined, -): Promise { - const { dryRun, spinner } = { - __proto__: null, - ...options, - } as ProcessFilePatchOptions - - const wasSpinning = !!spinner?.isSpinning - - spinner?.stop() - - const filepath = path.join(pkgPath, fileName) - if (!existsSync(filepath)) { - logger.log(`File not found: ${fileName}`) - if (wasSpinning) { - spinner?.start() - } - return false - } - - const currentHashResult = await computeSHA256(filepath) - if (!currentHashResult.ok) { - logger.log( - `Failed to compute hash for: ${fileName}: ${currentHashResult.cause || currentHashResult.message}`, - ) - if (wasSpinning) { - spinner?.start() - } - return false - } - - if (currentHashResult.data === fileInfo.afterHash) { - logger.success(`File already patched: ${fileName}`) - logger.group() - logger.log(`Current hash: ${currentHashResult.data}`) - logger.groupEnd() - if (wasSpinning) { - spinner?.start() - } - return true - } - - if (currentHashResult.data !== fileInfo.beforeHash) { - logger.fail(`File hash mismatch: ${fileName}`) - logger.group() - logger.log(`Expected: ${fileInfo.beforeHash}`) - logger.log(`Current: ${currentHashResult.data}`) - logger.log(`Target: ${fileInfo.afterHash}`) - logger.groupEnd() - if (wasSpinning) { - spinner?.start() - } - return false - } - - logger.success(`File matches expected hash: ${fileName}`) - logger.group() - logger.log(`Current hash: ${currentHashResult.data}`) - logger.log(`Ready to patch to: ${fileInfo.afterHash}`) - logger.group() - - if (dryRun) { - logger.log(`(dry run - no changes made)`) - logger.groupEnd() - logger.groupEnd() - if (wasSpinning) { - spinner?.start() - } - return false - } - - const blobPath = path.join(socketDir, 'blobs', fileInfo.afterHash) - if (!existsSync(blobPath)) { - logger.fail(`Error: Patch file not found at ${blobPath}`) - logger.groupEnd() - logger.groupEnd() - if (wasSpinning) { - spinner?.start() - } - return false - } - - spinner?.start() - - let result = true - try { - await fs.copyFile(blobPath, filepath) - - // Verify the hash after copying to ensure file integrity. - const verifyHashResult = await computeSHA256(filepath) - if (!verifyHashResult.ok) { - logger.error( - `Failed to verify hash after patch: ${verifyHashResult.cause || verifyHashResult.message}`, - ) - result = false - } else if (verifyHashResult.data !== fileInfo.afterHash) { - logger.error(`Hash verification failed after patch`) - logger.group() - logger.log(`Expected: ${fileInfo.afterHash}`) - logger.log(`Got: ${verifyHashResult.data}`) - logger.groupEnd() - result = false - } else { - logger.success(`Patch applied successfully`) - } - } catch (e) { - logger.error('Error applying patch') - debugDir('error', e) - result = false - } - logger.groupEnd() - logger.groupEnd() - - spinner?.stop() - - if (wasSpinning) { - spinner?.start() - } - return result -} - -export interface HandlePatchConfig { - cwd: string - dryRun: boolean - outputKind: OutputKind - purlObjs: PackageURL[] - spinner: Spinner -} - -export async function handlePatch({ - cwd, - dryRun, - outputKind, - purlObjs, - spinner, -}: HandlePatchConfig): Promise { - try { - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - const manifestContent = await fs.readFile(manifestPath, UTF8) - const manifestData = JSON.parse(manifestContent) - const purls = purlObjs.map(String) - const validated = PatchManifestSchema.parse(manifestData) - - // Parse PURLs and group by ecosystem. - const patchesByEcosystem = new Map() - for (const { 0: key, 1: patch } of Object.entries(validated.patches)) { - const purl = normalizePurl(key) - if (purls.length && !purls.includes(purl)) { - continue - } - const purlObj = getPurlObject(purl, { throws: false }) - if (!purlObj) { - continue - } - let patches = patchesByEcosystem.get(purlObj.type) - if (!Array.isArray(patches)) { - patches = [] - patchesByEcosystem.set(purlObj.type, patches) - } - patches.push({ - key, - patch, - purl, - purlObj, - }) - } - - if (purls.length) { - spinner.start(`Checking patches for: ${joinAnd(purls)}`) - } else { - spinner.start('Scanning all dependencies for available patches') - } - - const patched = [] - - const npmPatches = patchesByEcosystem.get(NPM) - if (npmPatches) { - const patchingResults = await applyNpmPatches( - dotSocketDirPath, - npmPatches, - { - cwd, - dryRun, - purlObjs, - spinner, - }, - ) - patched.push(...patchingResults.passed) - } - - spinner.stop() - - await outputPatchResult( - { - ok: true, - data: { - patched, - }, - }, - outputKind, - ) - } catch (e) { - spinner.stop() - - let message = 'Failed to apply patches' - let cause = getErrorCause(e) - - if (e instanceof SyntaxError) { - message = `Invalid JSON in ${MANIFEST_JSON}` - cause = e.message - } else if (e instanceof Error && 'issues' in e) { - message = 'Schema validation failed' - cause = String(e) - } - - await outputPatchResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} diff --git a/src/commands/patch/manifest-schema.mts b/src/commands/patch/manifest-schema.mts deleted file mode 100644 index f95344e52..000000000 --- a/src/commands/patch/manifest-schema.mts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod' - -export type PatchManifest = z.infer - -export type PatchRecord = z.infer - -export const PatchRecordSchema = z.object({ - exportedAt: z.string(), - files: z.record( - z.string(), // File path - z.object({ - beforeHash: z.string(), - afterHash: z.string(), - }), - ), - vulnerabilities: z.record( - z.string(), // Vulnerability ID like "GHSA-jrhj-2j3q-xf3v" - z.object({ - cves: z.array(z.string()), - summary: z.string(), - severity: z.string(), - description: z.string(), - patchExplanation: z.string(), - }), - ), -}) - -export const PatchManifestSchema = z.object({ - patches: z.record( - // Package identifier like "npm:simplehttpserver@0.0.6". - z.string(), - PatchRecordSchema, - ), -}) diff --git a/src/commands/patch/output-patch-result.mts b/src/commands/patch/output-patch-result.mts deleted file mode 100644 index 8884c2bfd..000000000 --- a/src/commands/patch/output-patch-result.mts +++ /dev/null @@ -1,46 +0,0 @@ -import { logger } from '@socketsecurity/registry/lib/logger' -import { pluralize } from '@socketsecurity/registry/lib/words' - -import { OUTPUT_JSON } from '../../constants.mts' -import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../utils/serialize-result-json.mts' - -import type { CResult, OutputKind } from '../../types.mts' - -export async function outputPatchResult( - result: CResult<{ patched: string[] }>, - outputKind: OutputKind, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === OUTPUT_JSON) { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { patched } = result.data - - logger.log('') - - if (patched.length) { - logger.group( - `Successfully processed patches for ${patched.length} ${pluralize('package', patched.length)}:`, - ) - for (const pkg of patched) { - logger.success(pkg) - } - logger.groupEnd() - } else { - logger.warn('No packages found requiring patches.') - } - - logger.log('') - logger.success('Patch command completed!') -} diff --git a/src/constants.mts b/src/constants.mts index 1e31500b5..a44962257 100644 --- a/src/constants.mts +++ b/src/constants.mts @@ -167,6 +167,7 @@ export type ENV = Remap< SOCKET_CLI_NPM_PATH: string SOCKET_CLI_ORG_SLUG: string SOCKET_CLI_VIEW_ALL_RISKS: boolean + SOCKET_PATCH_PROXY_URL: string TERM: string XDG_DATA_HOME: string }> @@ -646,6 +647,8 @@ const LAZY_ENV = () => { envAsString(env['SOCKET_ORG_SLUG']), // View all risks of a Socket wrapped npm/npx run. SOCKET_CLI_VIEW_ALL_RISKS: envAsBoolean(env[SOCKET_CLI_VIEW_ALL_RISKS]), + // Override the public patch API proxy URL for socket-patch. + SOCKET_PATCH_PROXY_URL: envAsString(env['SOCKET_PATCH_PROXY_URL']), // Specifies the type of terminal or terminal emulator being used by the process. TERM: envAsString(env['TERM']), // Redefine registryConstants.ENV.VITEST to account for the From b16dc48465e46825bc79a545b369b7e5263041a4 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 19 Dec 2025 15:55:00 -0500 Subject: [PATCH 2/2] update lockfile --- pnpm-lock.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11d24c6be..6abc83cdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: '@socketsecurity/sdk': specifier: 1.4.95 version: 1.4.95 + '@socketsecurity/socket-patch': + specifier: 1.0.0 + version: 1.0.0 '@types/blessed': specifier: 0.1.25 version: 0.1.25 @@ -1745,6 +1748,11 @@ packages: resolution: {integrity: sha512-rUqo8UYHsH8MQxO8EKnIAsU8AhArz0A3H2hfDgZPrfpY2O7ligUUBaLkk/zEm9DP6k8JjWSR6gxdvnY6KgWQJQ==} engines: {node: '>=18'} + '@socketsecurity/socket-patch@1.0.0': + resolution: {integrity: sha512-B8kT5DEF7rD97N8UohenFGuqGVlS82RlUwF31yWzzi8bw2YEUM0vxdYE1pZveByd+fexj2zpY8nXK0cKF6+eeQ==} + engines: {node: '>=18.0.0'} + hasBin: true + '@stroncium/procfs@1.2.1': resolution: {integrity: sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==} engines: {node: '>=8'} @@ -6379,6 +6387,11 @@ snapshots: dependencies: '@socketsecurity/registry': 1.1.17 + '@socketsecurity/socket-patch@1.0.0': + dependencies: + yargs: 17.7.2 + zod: 3.25.76 + '@stroncium/procfs@1.2.1': {} '@szmarczak/http-timer@5.0.1':