diff --git a/.config/rollup.base.config.mjs b/.config/rollup.base.config.mjs index 9ad11c294..b601787c9 100644 --- a/.config/rollup.base.config.mjs +++ b/.config/rollup.base.config.mjs @@ -27,6 +27,7 @@ import { normalizeId, resolveId } from '../scripts/utils/packages.js' +import { envAsBoolean } from '@socketsecurity/registry/lib/env' const require = createRequire(import.meta.url) @@ -45,6 +46,11 @@ const { tsconfigPath } = constants +const IS_SENTRY_BUILD = envAsBoolean(process.env['SOCKET_WITH_SENTRY']); +console.log('IS_SENTRY_BUILD:', IS_SENTRY_BUILD); +const IS_PUBLISH = envAsBoolean(process.env['SOCKET_IS_PUBLISHED']) +console.log('IS_PUBLISH:', IS_PUBLISH); + const SOCKET_INTEROP = '_socketInterop' const constantsSrcPath = path.join(rootSrcPath, `${CONSTANTS}.ts`) @@ -125,6 +131,33 @@ function isAncestorsExternal(id, depStats) { return true } + +function sentryAliasingPlugin() { + return { + name: 'sentry-alias-plugin', + order: 'post', + resolveId(source, importer) { + // By default use the noop file for crash handler. + // When at build-time the `SOCKET_WITH_SENTRY` flag is set, route to use + // the Sentry specific files instead. + if (source === './initialize-crash-handler') { + return IS_SENTRY_BUILD + ? `${rootSrcPath}/initialize-sentry.ts` + : `${rootSrcPath}/initialize-crash-handler.ts`; + } + + if (source === './handle-crash') { + return IS_SENTRY_BUILD + ? `${rootSrcPath}/handle-crash-with-sentry.ts` + : `${rootSrcPath}/handle-crash.ts`; + } + + return null; + } + }; +} + + export default function baseConfig(extendConfig = {}) { const depStats = { dependencies: { __proto__: null }, @@ -215,6 +248,7 @@ export default function baseConfig(extendConfig = {}) { }, ...extendConfig, plugins: [ + sentryAliasingPlugin(), // Should go real early. customResolver, jsonPlugin(), tsPlugin({ diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index 1f9968344..df5371696 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -3,9 +3,12 @@ import { existsSync, mkdirSync, rmSync, - writeFileSync + writeFileSync, + readFileSync } from 'node:fs' import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { globSync as tinyGlobSync } from 'tinyglobby' @@ -26,6 +29,8 @@ import { isBuiltin, normalizeId } from '../scripts/utils/packages.js' +import { envAsBoolean } from '@socketsecurity/registry/lib/env' +import assert from 'node:assert' const { BABEL_RUNTIME, @@ -44,6 +49,9 @@ const CONSTANTS_JS = `${CONSTANTS}.js` const CONSTANTS_STUB_CODE = createStubCode(`../${CONSTANTS_JS}`) const VENDOR_JS = `${VENDOR}.js` +const IS_SENTRY_BUILD = envAsBoolean(process.env['SOCKET_WITH_SENTRY']); +const IS_PUBLISH = envAsBoolean(process.env['SOCKET_IS_PUBLISHED']) + const distConstantsPath = path.join(rootDistPath, CONSTANTS_JS) const distModuleSyncPath = path.join(rootDistPath, MODULE_SYNC) const distRequirePath = path.join(rootDistPath, REQUIRE) @@ -52,6 +60,10 @@ const editablePkgJson = readPackageJsonSync(rootPath, { editable: true }) const processEnvTapRegExp = /\bprocess\.env(?:\.TAP|\[['"]TAP['"]\])(\s*\?[^:]+:\s*)?/g +const processEnvSocketIsPublishedRegExp = + /\bprocess\.env(?:\.SOCKET_IS_PUBLISHED|\[['"]SOCKET_IS_PUBLISHED['"]\])/g +const processEnvSocketCliVersionRegExp = + /\bprocess\.env(?:\.SOCKET_CLI_VERSION|\[['"]SOCKET_CLI_VERSION['"]\])/g function createStubCode(relFilepath) { return `'use strict'\n\nmodule.exports = require('${relFilepath}')\n` @@ -104,6 +116,20 @@ function updateDepStatsSync(depStats) { delete depStats.dependencies[key] } } + + assert(Object.keys(editablePkgJson?.content?.bin).join(',') === 'socket,socket-npm,socket-npx', 'If this fails, make sure to update the rollup sentry override for .bin to match the regular build!'); + if (IS_SENTRY_BUILD) { + editablePkgJson.content['name'] = '@socketsecurity/socket-with-sentry' + editablePkgJson.content['description'] = "CLI tool for Socket.dev, includes Sentry error handling, otherwise identical to the regular `socket` package" + editablePkgJson.content['bin'] = { + "socket-with-sentry": "bin/cli.js", + "socket-npm-with-sentry": "bin/npm-cli.js", + "socket-npx-with-sentry": "bin/npx-cli.js" + } + // Add Sentry as a regular dep for this build + depStats.dependencies['@sentry/node'] = '9.1.0'; + } + depStats.dependencies = toSortedObject(depStats.dependencies) depStats.devDependencies = toSortedObject(depStats.devDependencies) depStats.esm = toSortedObject(depStats.esm) @@ -111,6 +137,7 @@ function updateDepStatsSync(depStats) { depStats.transitives = toSortedObject(depStats.transitives) // Write dep stats. writeFileSync(depStatsPath, `${formatObject(depStats)}\n`, 'utf8') + // Update dependencies with additional inlined modules. editablePkgJson .update({ @@ -120,6 +147,40 @@ function updateDepStatsSync(depStats) { } }) .saveSync() + + if (IS_SENTRY_BUILD) { + // Replace the name in the package lock too, just in case. + const lock = readFileSync('package-lock.json', 'utf8'); + // Note: this should just replace the first occurrence, even if there are more + const lock2 = lock.replace('"name": "socket",', '"name": "@socketsecurity/socket-with-sentry",') + writeFileSync('package-lock.json', lock2) + } +} + +function versionBanner(_chunk) { + let pkgJsonVersion = 'unknown'; + try { pkgJsonVersion = JSON.parse(readFileSync('package.json', 'utf8'))?.version ?? 'unknown' } catch {} + + let gitHash = '' + try { + const obj = spawnSync('git', ['rev-parse','--short', 'HEAD']); + if (obj.stdout) { + gitHash = obj.stdout.toString('utf8').trim() + } + } catch {} + + // Make each build generate a unique version id, regardless + // Mostly for development: confirms the build refreshed. For prod + // builds the git hash should suffice to identify the build. + const rng = randomUUID().split('-')[0]; + + return ` + var SOCKET_CLI_PKG_JSON_VERSION = "${pkgJsonVersion}" + var SOCKET_CLI_GIT_HASH = "${gitHash}" + var SOCKET_CLI_BUILD_RNG = "${rng}" + var SOCKET_PUB = ${IS_PUBLISH} + var SOCKET_CLI_VERSION = "${pkgJsonVersion}:${gitHash}:${rng}${IS_PUBLISH ? ':pub':''}" + `.trim().split('\n').map(s => s.trim()).join('\n') } export default () => { @@ -132,12 +193,15 @@ export default () => { }, output: [ { + intro: versionBanner, // Note: "banner" would defeat "use strict" dir: path.relative(rootPath, distModuleSyncPath), entryFileNames: '[name].js', exports: 'auto', externalLiveBindings: false, format: 'cjs', - freeze: false + freeze: false, + sourcemap: true, + sourcemapDebugIds: true, } ], external(id_) { @@ -182,12 +246,15 @@ export default () => { }, output: [ { + intro: versionBanner, // Note: "banner" would defeat "use strict" dir: path.relative(rootPath, distRequirePath), entryFileNames: '[name].js', exports: 'auto', externalLiveBindings: false, format: 'cjs', - freeze: false + freeze: false, + sourcemap: true, + sourcemapDebugIds: true, } ], plugins: [ @@ -197,6 +264,19 @@ export default () => { find: processEnvTapRegExp, replace: (_match, ternary) => (ternary ? '' : 'false') }), + // Replace `process.env.SOCKET_IS_PUBLISHED` with a boolean + socketModifyPlugin({ + find: processEnvSocketIsPublishedRegExp, + // Note: these are going to be bools in JS, not strings + replace: () => (IS_PUBLISH ? 'true' : 'false') + }), + // Replace `process.env.SOCKET_CLI_VERSION` with var ref that rollup + // adds to the top of each file. + socketModifyPlugin({ + find: processEnvSocketCliVersionRegExp, + replace: 'SOCKET_CLI_VERSION' + }), + { generateBundle(_options, bundle) { for (const basename of Object.keys(bundle)) { diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 39d496f06..5221208c6 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -21,7 +21,11 @@ jobs: scope: "@socketsecurity" - run: npm install -g npm@latest - run: npm ci - - run: npm run build:dist + - run: SOCKET_IS_PUBLISHED=1 npm run build:dist + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: SOCKET_IS_PUBLISHED=1 SOCKET_WITH_SENTRY=1 npm run build:dist - run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/src/cli.ts b/src/cli.ts index 9537a9dcd..75a958889 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,8 @@ #!/usr/bin/env node +// Keep this on top, no `from`, just init: +import './initialize-crash-handler' + import process from 'node:process' import { pathToFileURL } from 'node:url' @@ -20,6 +23,7 @@ import { cmdLogout } from './commands/logout/cmd-logout' import { cmdManifest } from './commands/manifest/cmd-manifest' import { cmdNpm } from './commands/npm/cmd-npm' import { cmdNpx } from './commands/npx/cmd-npx' +import { cmdOops } from './commands/oops/cmd-oops' import { cmdOptimize } from './commands/optimize/cmd-optimize' import { cmdOrganizations } from './commands/organizations/cmd-organizations' import { cmdRawNpm } from './commands/raw-npm/cmd-raw-npm' @@ -30,6 +34,7 @@ import { cmdScan } from './commands/scan/cmd-scan' import { cmdThreatFeed } from './commands/threat-feed/cmd-threat-feed' import { cmdWrapper } from './commands/wrapper/cmd-wrapper' import constants from './constants' +import { handle } from './handle-crash' import { AuthError, InputError } from './utils/errors' import { logSymbols } from './utils/logging' import { meowWithSubcommands } from './utils/meow-with-subcommands' @@ -55,6 +60,7 @@ void (async () => { logout: cmdLogout, npm: cmdNpm, npx: cmdNpx, + oops: cmdOops, optimize: cmdOptimize, organization: cmdOrganizations, 'raw-npm': cmdRawNpm, @@ -106,6 +112,9 @@ void (async () => { if (errorBody) { console.error(`\n${errorBody}`) } - process.exit(1) + + process.exitCode = 1 + + await handle(err) } })() diff --git a/src/commands/oops/cmd-oops.ts b/src/commands/oops/cmd-oops.ts new file mode 100644 index 000000000..51a991f56 --- /dev/null +++ b/src/commands/oops/cmd-oops.ts @@ -0,0 +1,37 @@ +import meowOrExit from 'meow' + +import { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' + +const config: CliCommandConfig = { + commandName: 'oops', + description: 'Trigger an intentional error (for development)', + hidden: true, + flags: {}, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Don't run me. + ` +} + +export const cmdOops = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + throw new Error('This error was intentionally left blank') +} diff --git a/src/constants.ts b/src/constants.ts index 3c436c52c..f5a14d077 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -57,6 +57,7 @@ type Constants = Omit< readonly ENV: ENV readonly DIST_TYPE: 'module-sync' | 'require' readonly IPC: IPC + readonly IS_PUBLISHED: boolean readonly LOCK_EXT: '.lock' readonly MODULE_SYNC: 'module-sync' readonly NPM_REGISTRY_URL: 'https://registry.npmjs.org' @@ -95,6 +96,7 @@ const BUN = 'bun' const CVE_ALERT_PROPS_FIRST_PATCHED_VERSION_IDENTIFIER = 'firstPatchedVersionIdentifier' const CVE_ALERT_PROPS_VULNERABLE_VERSION_RANGE = 'vulnerableVersionRange' +const IS_PUBLISHED = process.env['SOCKET_IS_PUBLISHED'] const LOCK_EXT = '.lock' const MODULE_SYNC = 'module-sync' const NPM_REGISTRY_URL = 'https://registry.npmjs.org' @@ -178,6 +180,7 @@ const constants = createConstantsObject( // Lazily defined values are initialized as `undefined` to keep their key order. DIST_TYPE: undefined, ENV: undefined, + IS_PUBLISHED, LOCK_EXT, MODULE_SYNC, NPM_REGISTRY_URL, diff --git a/src/handle-crash-with-sentry.ts b/src/handle-crash-with-sentry.ts new file mode 100644 index 000000000..25287c0fb --- /dev/null +++ b/src/handle-crash-with-sentry.ts @@ -0,0 +1,25 @@ +// In a Sentry build, this file will replace the `handle_crash.ts`, see rollup. +// +// This is intended to send a caught-but-unexpected exception to Sentry +// It only works in a special @socketsecurity/cli-with-sentry build. +// +// The regular build will not have the Sentry dependency at all because we +// don't want to give people the idea that we're using it to gather telemetry. + +// @ts-ignore +import * as sentry from '@sentry/node' + +// Note: Make sure not to exit() explicitly after calling this command. Sentry +// needs some time to finish the fetch() but it doesn't return a promise. +export async function handle(err: unknown) { + if (process.env['SOCKET_CLI_DEBUG'] === '1') { + console.log('Sending to Sentry...') + } + sentry.captureException(err) + if (process.env['SOCKET_CLI_DEBUG'] === '1') { + console.log('Request to Sentry initiated.') + } + + // "Sleep" for a second, just in case, hopefully enough time to initiate fetch + return await new Promise(r => setTimeout(r, 1000)) +} diff --git a/src/handle-crash.ts b/src/handle-crash.ts new file mode 100644 index 000000000..5dcadca40 --- /dev/null +++ b/src/handle-crash.ts @@ -0,0 +1,11 @@ +// By default this doesn't do anything. +// There's a special cli package in the @socketsecurity scope that is identical +// to this package, except it actually handles error crash reporting. + +import { envAsBoolean } from '@socketsecurity/registry/lib/env' + +export async function handle(err: unknown) { + if (envAsBoolean(process.env['SOCKET_CLI_DEBUG'])) { + console.error('An unexpected but caught error happened:', err) + } +} diff --git a/src/initialize-crash-handler.ts b/src/initialize-crash-handler.ts new file mode 100644 index 000000000..77d1f671e --- /dev/null +++ b/src/initialize-crash-handler.ts @@ -0,0 +1,3 @@ +// This is a placeholder +// In a special @socketsecurity scoped build this will hold crash handler init +// See the rollup config for details. diff --git a/src/initialize-sentry.ts b/src/initialize-sentry.ts new file mode 100644 index 000000000..aecabf22c --- /dev/null +++ b/src/initialize-sentry.ts @@ -0,0 +1,56 @@ +// This should ONLY be included in the special Sentry build! Otherwise the +// Sentry dependency won't even be present in the manifest. +// This file should then be imported once at the top of any entrypoint as: +// ``` +// import './utils/initialize-sentry' +// ``` +// (no "from"; this doesn't export anything). That will setup the error hooks. + +// Note: KEEP DEPS HERE TO A MINIMUM. Sentry should be first thing to run. +// @ts-ignore +import * as Sentry from '@sentry/node' + +// Enabled in this build unless explicitly disabled for some reason +const ENABLE_SENTRY = process.env['SOCKET_DISABLE_SENTRY'] === '1' + +const debugging = process.env['SOCKET_CLI_DEBUG'] === '1' + +if (ENABLE_SENTRY) { + if (debugging) { + console.log('[DEBUG] Setting up Sentry...') + } + Sentry.init({ + // debug: true, + onFatalError(error: Error) { + if (debugging) { + console.error('[DEBUG] [Sentry onFatalError]:', error) + } + }, + enabled: ENABLE_SENTRY, + + dsn: 'https://66736701db8e4ffac046bd09fa6aaced@o555220.ingest.us.sentry.io/4508846967619585', + integrations: [] + }) + Sentry.setTag( + 'environment', + // @ts-ignore + typeof SOCKET_PUB !== 'undefined' && SOCKET_PUB + ? 'pub' + : process.env['NODE_ENV'] + ) + Sentry.setTag('debugging', debugging) + // The version variable should be generated by rollup and injected at the top + Sentry.setTag( + 'version', + // @ts-ignore + typeof SOCKET_CLI_VERSION !== 'undefined' ? SOCKET_CLI_VERSION : 'unknown' + ) + + if (debugging) { + console.log('[DEBUG] Set up Sentry.') + } +} else { + if (debugging) { + console.log('[DEBUG] Sentry disabled explicitly.') + } +}