diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..5a88365f7d --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,155 @@ +# Third-Party Notices + +Electron Fiddle incorporates third-party software components under +various licenses. The following notices are provided in compliance +with those licenses. + +--- + +## Socket Firewall (sfw) + +- **npm package (`sfw`)**: +- **License**: MIT +- **Copyright**: Socket Inc. + +The `sfw` npm package is an MIT-licensed wrapper that downloads and +manages the Socket Firewall Free binary. + +--- + +## Socket Firewall Free (sfw-free) + +- **Source**: +- **License**: PolyForm Shield License 1.0.0 +- **Copyright**: Socket Inc. + +The `sfw-free` binary, downloaded at runtime by the `sfw` npm +package, is licensed under the PolyForm Shield License 1.0.0. +The full license text is available at: + + +### PolyForm Shield License 1.0.0 + +> ## Acceptance +> +> In order to get any license under these terms, you must agree +> to them as both strict obligations and conditions to all +> your licenses. +> +> ## Copyright License +> +> The licensor grants you a copyright license for the +> software to do everything you might do with the software +> that would otherwise infringe the licensor's copyright +> in it for any permitted purpose. However, you may +> only distribute the software according to Distribution +> License and make changes or new works based on the software +> according to Changes and New Works License. +> +> ## Distribution License +> +> The licensor grants you an additional copyright license +> to distribute copies of the software. Your license +> to distribute covers distributing the software with +> changes and new works permitted by Changes and New Works +> License. +> +> ## Notices +> +> You must ensure that anyone who gets a copy of any part of +> the software from you also gets a copy of these terms or the +> URL for them above, as well as copies of any plain-text lines +> beginning with `Required Notice:` that the licensor provided +> with the software. +> +> ## Changes and New Works License +> +> The licensor grants you an additional copyright license to +> make changes and new works based on the software for any +> permitted purpose. +> +> ## Patent License +> +> The licensor grants you a patent license for the software that +> covers patent claims the licensor can license, or becomes able +> to license, that you would infringe by using the software. +> +> ## Noncompete +> +> Any purpose is a permitted purpose, except for providing any +> product that competes with the software or any product the +> licensor or any of its affiliates provides using the software. +> +> ## Competition +> +> Goods and services compete even when they provide functionality +> through different kinds of interfaces or for different technical +> platforms. Applications can compete with services, libraries +> with plugins, frameworks with development tools, and so on, +> even if they're written in different programming languages +> or for different computer architectures. Goods and services +> compete even when provided free of charge. If you market a +> product as a practical substitute for the software or another +> product, it definitely competes. +> +> ## New Products +> +> If you are using the software to provide a product that does +> not compete, but the licensor or any of its affiliates brings +> your product into competition by providing a new version of +> the software or another product using the software, you may +> continue using versions of the software available under these +> terms beforehand to provide your competing product, but not +> any later versions. +> +> ## Discontinued Products +> +> You may begin using the software to compete with a product +> or service that the licensor or any of its affiliates has +> stopped providing, unless the licensor includes a plain-text +> line beginning with `Licensor Line of Business:` with the +> software that mentions that line of business. +> +> ## Sales of Business +> +> If the licensor or any of its affiliates sells a line of +> business developing the software or using the software +> to provide a product, the buyer can also enforce +> Noncompete for that product. +> +> ## Fair Use +> +> You may have "fair use" rights for the software under the +> law. These terms do not limit them. +> +> ## No Other Rights +> +> These terms do not allow you to sublicense or transfer any of +> your licenses to anyone else, or prevent the licensor from +> granting licenses to anyone else. These terms do not imply +> any other licenses. +> +> ## Patent Defense +> +> If you make any written claim that the software infringes or +> contributes to infringement of any patent, your patent license +> for the software granted under these terms ends immediately. If +> your company makes such a claim, your patent license ends +> immediately for work on behalf of your company. +> +> ## Violations +> +> The first time you are notified in writing that you have +> violated any of these terms, or done anything with the software +> not covered by your licenses, your licenses can nonetheless +> continue if you come into full compliance with these terms, +> and take practical steps to correct past violations, within +> 32 days of receiving notice. Otherwise, all your licenses +> end immediately. +> +> ## No Liability +> +> ***As far as the law allows, the software comes as is, without +> any warranty or condition, and the licensor will not be liable +> to you for any damages arising out of these terms or the use +> or nature of the software, under any kind of legal claim.*** diff --git a/forge.config.ts b/forge.config.ts index 591a8e2822..1970165a5b 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -70,7 +70,10 @@ const config: ForgeConfig = { packagerConfig: { name: 'Electron Fiddle', executableName: 'electron-fiddle', - asar: true, + // Unpack the embedded sfw script so system Node can spawn it — it can't + // be executed from inside an asar archive. The `.webpack` segment must + // be explicit because minimatch globstar skips dot-prefixed directories. + asar: { unpack: '**/.webpack/sfw/**' }, icon: path.resolve(__dirname, 'assets', 'icons', 'fiddle'), appBundleId: 'com.electron.fiddle', usageDescription: { diff --git a/package.json b/package.json index ea1d8cfef1..97f7965669 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "react-mosaic-component": "^4.1.1", "react-window": "^1.8.10", "semver": "^7.3.4", + "sfw": "^2.0.4", "shell-env": "^3.0.1", "tmp": "0.2.5", "tslib": "^2.6.0", diff --git a/src/ambient.d.ts b/src/ambient.d.ts index e2ba82b09a..c921876841 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -100,7 +100,7 @@ declare global { listener: (types: string, version: string) => void, ): void; addModules( - { dir, packageManager }: PMOperationOptions, + { dir, packageManager, useSocketFirewall }: PMOperationOptions, ...names: Array ): Promise; arch: string; diff --git a/src/interfaces.ts b/src/interfaces.ts index dd273da6d4..779757a015 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -213,6 +213,7 @@ export type IPackageManager = 'npm' | 'yarn'; export interface PMOperationOptions { dir: string; packageManager: IPackageManager; + useSocketFirewall?: boolean; } export interface GistRevision { @@ -243,6 +244,7 @@ export enum GlobalSetting { isEnablingElectronLogging = 'isEnablingElectronLogging', isKeepingUserDataDirs = 'isKeepingUserDataDirs', isPublishingGistAsRevision = 'isPublishingGistAsRevision', + isUsingSocketFirewall = 'isUsingSocketFirewall', isUsingSystemTheme = 'isUsingSystemTheme', knownVersion = 'known-electron-versions', localVersion = 'local-electron-versions', diff --git a/src/main/npm.ts b/src/main/npm.ts index 7003693e4c..7e0886772a 100644 --- a/src/main/npm.ts +++ b/src/main/npm.ts @@ -47,22 +47,45 @@ export async function getIsPackageManagerInstalled( } } +/** + * Returns the path to the embedded sfw script. + * The sfw CLI is bundled with the app via webpack CopyPlugin. + * Mirrors the original node_modules/sfw/ layout (dist/sfw.mjs + package.json) + * because sfw.mjs reads "../package.json" at runtime for its version. + * In a packaged app the sfw directory is asar-unpacked (see forge.config.ts) + * so system Node can read it — translate the virtual asar path accordingly. + */ +export function getSfwPath(): string { + return path + .resolve(__dirname, '../sfw/dist/sfw.mjs') + .replace( + `${path.sep}app.asar${path.sep}`, + `${path.sep}app.asar.unpacked${path.sep}`, + ); +} + /** * Installs given modules to a given folder. */ export async function addModules( - { dir, packageManager }: PMOperationOptions, + { dir, packageManager, useSocketFirewall }: PMOperationOptions, ...names: Array ): Promise { - const cmd = packageManager === 'npm' ? 'npm' : 'yarn'; - const args = + const pm = packageManager === 'npm' ? 'npm' : 'yarn'; + const pmArgs = packageManager === 'npm' ? ['install', '-S', ...names] : names.length > 0 ? ['add', ...names] : ['install']; - return await execFile(dir, cmd, args); + // Use Socket Firewall if enabled + if (useSocketFirewall) { + // Run the embedded sfw script via system node: node sfw.mjs npm install ... + return await execFile(dir, 'node', [getSfwPath(), pm, ...pmArgs]); + } + + return await execFile(dir, pm, pmArgs); } /** @@ -84,9 +107,9 @@ export async function setupNpm() { IpcEvents.NPM_ADD_MODULES, ( _: IpcMainInvokeEvent, - { dir, packageManager }: PMOperationOptions, + { dir, packageManager, useSocketFirewall }: PMOperationOptions, ...names: Array - ) => addModules({ dir, packageManager }, ...names), + ) => addModules({ dir, packageManager, useSocketFirewall }, ...names), ); ipcMainManager.handle( IpcEvents.NPM_IS_PM_INSTALLED, diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 3684239b7f..1ef770b30a 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -76,12 +76,12 @@ export async function setupFiddleGlobal() { } }, addModules( - { dir, packageManager }: PMOperationOptions, + { dir, packageManager, useSocketFirewall }: PMOperationOptions, ...names: Array ) { return ipcRenderer.invoke( IpcEvents.NPM_ADD_MODULES, - { dir, packageManager }, + { dir, packageManager, useSocketFirewall }, ...names, ); }, diff --git a/src/renderer/components/settings-execution.tsx b/src/renderer/components/settings-execution.tsx index 0ac5d7745e..7dfe47203b 100644 --- a/src/renderer/components/settings-execution.tsx +++ b/src/renderer/components/settings-execution.tsx @@ -57,6 +57,8 @@ export const ExecutionSettings = observer( this.handleDeleteDataChange = this.handleDeleteDataChange.bind(this); this.handleElectronLoggingChange = this.handleElectronLoggingChange.bind(this); + this.handleSocketFirewallChange = + this.handleSocketFirewallChange.bind(this); this.handleSettingsItemChange = this.handleSettingsItemChange.bind(this); this.addNewSettingsItem = this.addNewSettingsItem.bind(this); @@ -93,6 +95,16 @@ export const ExecutionSettings = observer( this.props.appState.isEnablingElectronLogging = checked; } + /** + * Handles a change on whether or not to use Socket Firewall for npm installs + */ + public handleSocketFirewallChange( + event: React.FormEvent, + ) { + const { checked } = event.currentTarget; + this.props.appState.isUsingSocketFirewall = checked; + } + /** * Handles a change in the execution flags or environment variables * run with the Electron executable. @@ -243,8 +255,11 @@ export const ExecutionSettings = observer( } public render() { - const { isKeepingUserDataDirs, isEnablingElectronLogging } = - this.props.appState; + const { + isKeepingUserDataDirs, + isEnablingElectronLogging, + isUsingSocketFirewall, + } = this.props.appState; return (
@@ -348,6 +363,29 @@ export const ExecutionSettings = observer( +
+ + +

+ + Socket Firewall + {' '} + protects against supply chain attacks by scanning packages + during installation. When enabled, Fiddle runs npm/yarn installs + through the sfw CLI, which blocks malicious dependencies before + they can execute. +

+ +
+
); } diff --git a/src/renderer/runner.ts b/src/renderer/runner.ts index 1060974ad4..2048226c40 100644 --- a/src/renderer/runner.ts +++ b/src/renderer/runner.ts @@ -183,11 +183,12 @@ export class Runner { const dir = await this.saveToTemp(options); const packageManager = appState.packageManager; + const useSocketFirewall = appState.isUsingSocketFirewall; if (!dir) return RunResult.INVALID; try { - await this.installModules({ dir, packageManager }); + await this.installModules({ dir, packageManager, useSocketFirewall }); } catch (error: any) { console.error('Runner: Could not install modules', error); @@ -249,6 +250,7 @@ export class Runner { pushOutput(`📦 ${strings[0]} current Fiddle...`); const packageManager = this.appState.packageManager; + const useSocketFirewall = this.appState.isUsingSocketFirewall; const pmInstalled = await window.ElectronFiddle.getIsPackageManagerInstalled(packageManager); if (!pmInstalled) { @@ -266,7 +268,10 @@ export class Runner { if (!dir) return false; // Files are now saved to temp, let's install Forge and dependencies - if (!(await this.packageInstall({ dir, packageManager }))) return false; + if ( + !(await this.packageInstall({ dir, packageManager, useSocketFirewall })) + ) + return false; // Cool, let's run "package" try { diff --git a/src/renderer/state.ts b/src/renderer/state.ts index cfba2d5843..9ff7af0251 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -99,6 +99,9 @@ export class AppState { public isPublishingGistAsRevision = !!( this.retrieve(GlobalSetting.isPublishingGistAsRevision) ?? true ); + public isUsingSocketFirewall = !!( + this.retrieve(GlobalSetting.isUsingSocketFirewall) ?? true + ); public executionFlags: Array = (this.retrieve(GlobalSetting.executionFlags) as Array) === null ? [] @@ -243,6 +246,7 @@ export class AppState { isKeepingUserDataDirs: observable, isOnline: observable, isPublishingGistAsRevision: observable, + isUsingSocketFirewall: observable, isQuitting: observable, isRunning: observable, isSettingsShowing: observable, @@ -424,6 +428,7 @@ export class AppState { case GlobalSetting.isKeepingUserDataDirs: case GlobalSetting.isPublishingGistAsRevision: case GlobalSetting.isShowingGistHistory: + case GlobalSetting.isUsingSocketFirewall: case GlobalSetting.isUsingSystemTheme: case GlobalSetting.packageAuthor: case GlobalSetting.packageManager: @@ -499,6 +504,12 @@ export class AppState { this.isPublishingGistAsRevision, ), ); + autorun(() => + this.save( + GlobalSetting.isUsingSocketFirewall, + this.isUsingSocketFirewall, + ), + ); autorun(() => this.save(GlobalSetting.gitHubAvatarUrl, this.gitHubAvatarUrl), ); diff --git a/tests/main/npm.spec.ts b/tests/main/npm.spec.ts index 1ff0b43ae4..94c9a1204a 100644 --- a/tests/main/npm.spec.ts +++ b/tests/main/npm.spec.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addModules, getIsPackageManagerInstalled, + getSfwPath, packageRun, } from '../../src/main/npm'; import { exec, execFile } from '../../src/main/utils/exec'; @@ -118,6 +119,12 @@ describe('npm', () => { }); }); + describe('getSfwPath()', () => { + it('returns the path to the embedded sfw script', () => { + expect(getSfwPath()).toMatch(/sfw\.mjs$/); + }); + }); + describe('addModules()', () => { describe('npm', () => { it('attempts to install a single module', async () => { @@ -168,6 +175,70 @@ describe('npm', () => { ]); }); }); + + describe('with socket firewall', () => { + it('uses sfw when enabled for npm', async () => { + await addModules( + { + dir: '/my/directory', + packageManager: 'npm', + useSocketFirewall: true, + }, + 'lodash', + ); + + expect(execFile).toHaveBeenCalledWith( + '/my/directory', + 'node', + expect.arrayContaining([ + expect.stringMatching(/sfw\.mjs$/), + 'npm', + 'install', + '-S', + 'lodash', + ]), + ); + }); + + it('uses sfw when enabled for yarn', async () => { + await addModules( + { + dir: '/my/directory', + packageManager: 'yarn', + useSocketFirewall: true, + }, + 'lodash', + ); + + expect(execFile).toHaveBeenCalledWith( + '/my/directory', + 'node', + expect.arrayContaining([ + expect.stringMatching(/sfw\.mjs$/), + 'yarn', + 'add', + 'lodash', + ]), + ); + }); + + it('does not use sfw when disabled', async () => { + await addModules( + { + dir: '/my/directory', + packageManager: 'npm', + useSocketFirewall: false, + }, + 'lodash', + ); + + expect(execFile).toHaveBeenCalledWith('/my/directory', 'npm', [ + 'install', + '-S', + 'lodash', + ]); + }); + }); }); describe('packageRun()', () => { diff --git a/tools/webpack/webpack.main.config.ts b/tools/webpack/webpack.main.config.ts index 2e75785b70..d016e96e29 100644 --- a/tools/webpack/webpack.main.config.ts +++ b/tools/webpack/webpack.main.config.ts @@ -32,6 +32,8 @@ export const mainConfig: Configuration = { }, { from: 'static/show-me', to: '../static/show-me' }, { from: 'assets/icons/fiddle.png', to: '../assets/icons/fiddle.png' }, + { from: 'node_modules/sfw/dist/sfw.mjs', to: '../sfw/dist/sfw.mjs' }, + { from: 'node_modules/sfw/package.json', to: '../sfw/package.json' }, ], }), ], diff --git a/yarn.lock b/yarn.lock index ddc4a0387e..88b7cb7e35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6243,6 +6243,7 @@ __metadata: react-window: "npm:^1.8.10" resolve-url-loader: "npm:^5.0.0" semver: "npm:^7.3.4" + sfw: "npm:^2.0.4" shell-env: "npm:^3.0.1" standard: "npm:^17.1.0" stylelint: "npm:^15.10.1" @@ -8092,7 +8093,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:*, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -12373,6 +12374,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:*": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: "npm:^4.2.4" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/2f265dbad15897a43110a02dae55105c04d356ec4ed560723dcb9f0d34bc4fb2f13f79bb930e7561be10278e2314db5aca2527d5d3dcbbdee5e6b331d1571f6d + languageName: node + linkType: hard + "property-information@npm:^7.0.0": version: 7.1.0 resolution: "property-information@npm:7.1.0" @@ -13114,6 +13126,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:*, retry@npm:^0.13.1": + version: 0.13.1 + resolution: "retry@npm:0.13.1" + checksum: 10c0/9ae822ee19db2163497e074ea919780b1efa00431d197c7afdb950e42bf109196774b92a49fc9821f0b8b328a98eea6017410bfc5e8a0fc19c85c6d11adb3772 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -13121,13 +13140,6 @@ __metadata: languageName: node linkType: hard -"retry@npm:^0.13.1": - version: 0.13.1 - resolution: "retry@npm:0.13.1" - checksum: 10c0/9ae822ee19db2163497e074ea919780b1efa00431d197c7afdb950e42bf109196774b92a49fc9821f0b8b328a98eea6017410bfc5e8a0fc19c85c6d11adb3772 - languageName: node - linkType: hard - "reusify@npm:^1.0.4": version: 1.0.4 resolution: "reusify@npm:1.0.4" @@ -13512,6 +13524,21 @@ __metadata: languageName: node linkType: hard +"sfw@npm:^2.0.4": + version: 2.0.4 + resolution: "sfw@npm:2.0.4" + dependencies: + graceful-fs: "npm:*" + proper-lockfile: "npm:*" + retry: "npm:*" + signal-exit: "npm:*" + yocto-spinner: "npm:*" + bin: + sfw: dist/sfw.mjs + checksum: 10c0/dbe35b8c7cac028ca2346759d9d491288d4d33fcaa7b28417fec347a09c2d685e4488ab22194df54805f7267163b4fa77a07fb2f44a01f57396ca14db15d6bff + languageName: node + linkType: hard + "shallow-clone@npm:^3.0.0": version: 3.0.1 resolution: "shallow-clone@npm:3.0.1" @@ -13652,6 +13679,13 @@ __metadata: languageName: node linkType: hard +"signal-exit@npm:*, signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -13659,13 +13693,6 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": - version: 4.1.0 - resolution: "signal-exit@npm:4.1.0" - checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 - languageName: node - linkType: hard - "simple-git@npm:^3.5.0": version: 3.32.3 resolution: "simple-git@npm:3.32.3" @@ -15959,9 +15986,25 @@ __metadata: languageName: node linkType: hard +"yocto-spinner@npm:*": + version: 1.1.0 + resolution: "yocto-spinner@npm:1.1.0" + dependencies: + yoctocolors: "npm:^2.1.1" + checksum: 10c0/4aa515543da3ccde81eb1037ff194954926f86f442dcf3e3bc99e4393185979157cae1d636888e8df2fa959fa39fd4d0c4856dc09a2f96285b2197b9ee265e13 + languageName: node + linkType: hard + "yoctocolors-cjs@npm:^2.1.2": version: 2.1.3 resolution: "yoctocolors-cjs@npm:2.1.3" checksum: 10c0/584168ef98eb5d913473a4858dce128803c4a6cd87c0f09e954fa01126a59a33ab9e513b633ad9ab953786ed16efdd8c8700097a51635aafaeed3fef7712fa79 languageName: node linkType: hard + +"yoctocolors@npm:^2.1.1": + version: 2.1.2 + resolution: "yoctocolors@npm:2.1.2" + checksum: 10c0/b220f30f53ebc2167330c3adc86a3c7f158bcba0236f6c67e25644c3188e2571a6014ffc1321943bb619460259d3d27eb4c9cc58c2d884c1b195805883ec7066 + languageName: node + linkType: hard