-
Notifications
You must be signed in to change notification settings - Fork 194
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a start-up event handler that, if running on Windows, suggests the user to exclude the current installation directory from being scanned by the Windows Defender, if it is active on the computer. Extend the existing start-up preference page to allow the user to adjust settings for skipping the Windows Defender exclusion check at start-up for the current or all installations or to run it explicitly. Fixes https://gitlab.eclipse.org/eclipse-wg/ide-wg/ide-wg-dev-funded-efforts/ide-wg-dev-funded-program-planning-council-top-issues/-/issues/20
- Loading branch information
1 parent
e82fd5d
commit f91fa77
Showing
6 changed files
with
451 additions
and
4 deletions.
There are no files selected for viewing
2 changes: 1 addition & 1 deletion
2
bundles/org.eclipse.ui.workbench/.settings/org.eclipse.pde.ds.annotations.prefs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
347 changes: 347 additions & 0 deletions
347
....eclipse.ui.workbench/Eclipse UI/org/eclipse/ui/internal/WindowsDefenderConfigurator.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,347 @@ | ||
/******************************************************************************* | ||
* Copyright (c) 2023, 2023 Hannes Wellmann and others. | ||
* | ||
* This program and the accompanying materials | ||
* are made available under the terms of the Eclipse Public License 2.0 | ||
* which accompanies this distribution, and is available at | ||
* https://www.eclipse.org/legal/epl-2.0/ | ||
* | ||
* SPDX-License-Identifier: EPL-2.0 | ||
* | ||
* Contributors: | ||
* Hannes Wellmann - initial API and implementation | ||
*******************************************************************************/ | ||
|
||
package org.eclipse.ui.internal; | ||
|
||
import static org.eclipse.ui.internal.WorkbenchPlugin.PI_WORKBENCH; | ||
|
||
import java.io.BufferedReader; | ||
import java.io.File; | ||
import java.io.IOException; | ||
import java.net.URISyntaxException; | ||
import java.nio.charset.StandardCharsets; | ||
import java.nio.file.Path; | ||
import java.util.Arrays; | ||
import java.util.Base64; | ||
import java.util.Collection; | ||
import java.util.List; | ||
import java.util.Optional; | ||
import java.util.concurrent.ExecutionException; | ||
import java.util.concurrent.ExecutorService; | ||
import java.util.concurrent.Executors; | ||
import java.util.concurrent.Future; | ||
import java.util.stream.Collectors; | ||
import java.util.stream.Stream; | ||
import org.eclipse.core.runtime.CoreException; | ||
import org.eclipse.core.runtime.FileLocator; | ||
import org.eclipse.core.runtime.IProgressMonitor; | ||
import org.eclipse.core.runtime.OperationCanceledException; | ||
import org.eclipse.core.runtime.Platform; | ||
import org.eclipse.core.runtime.Status; | ||
import org.eclipse.core.runtime.SubMonitor; | ||
import org.eclipse.core.runtime.jobs.Job; | ||
import org.eclipse.core.runtime.preferences.ConfigurationScope; | ||
import org.eclipse.core.runtime.preferences.IEclipsePreferences; | ||
import org.eclipse.core.runtime.preferences.IPreferencesService; | ||
import org.eclipse.core.runtime.preferences.IScopeContext; | ||
import org.eclipse.core.runtime.preferences.UserScope; | ||
import org.eclipse.e4.ui.workbench.UIEvents; | ||
import org.eclipse.jface.dialogs.MessageDialog; | ||
import org.eclipse.jface.layout.GridDataFactory; | ||
import org.eclipse.jface.widgets.WidgetFactory; | ||
import org.eclipse.osgi.service.datalocation.Location; | ||
import org.eclipse.osgi.util.NLS; | ||
import org.eclipse.swt.SWT; | ||
import org.eclipse.swt.events.SelectionListener; | ||
import org.eclipse.swt.layout.GridData; | ||
import org.eclipse.swt.widgets.Button; | ||
import org.eclipse.swt.widgets.Composite; | ||
import org.eclipse.swt.widgets.Control; | ||
import org.eclipse.swt.widgets.Display; | ||
import org.eclipse.swt.widgets.Text; | ||
import org.eclipse.ui.PlatformUI; | ||
import org.osgi.framework.Bundle; | ||
import org.osgi.framework.FrameworkUtil; | ||
import org.osgi.service.component.annotations.Component; | ||
import org.osgi.service.component.annotations.Reference; | ||
import org.osgi.service.event.Event; | ||
import org.osgi.service.event.EventHandler; | ||
import org.osgi.service.event.propertytypes.EventTopics; | ||
import org.osgi.service.prefs.BackingStoreException; | ||
|
||
/** | ||
* A start-up event handler that, if running on Windows, suggests the user to | ||
* exclude the current installation directory from being scanned by the Windows | ||
* Defender, if it is active on the computer. | ||
*/ | ||
@Component(service = EventHandler.class) | ||
@EventTopics(UIEvents.UILifeCycle.APP_STARTUP_COMPLETE) | ||
public class WindowsDefenderConfigurator implements EventHandler { | ||
private static final String WINDOWS_DEFENDER_CHECKED_INSTALLATION_PATH = "windows.defender.checked.path"; //$NON-NLS-1$ | ||
public static final String PREFERENCE_SKIP = "windows.defender.check.skip"; //$NON-NLS-1$ | ||
public static final boolean PREFERENCE_SKIP_DEFAULT = false; | ||
|
||
@Reference | ||
private IPreferencesService preferences; | ||
|
||
@Override | ||
public void handleEvent(Event event) { | ||
if (isRelevant()) { | ||
Job job = Job.create(WorkbenchMessages.WindowsDefenderConfigurator_statusCheck, m -> { | ||
SubMonitor monitor = SubMonitor.convert(m, 10); | ||
if (preferences.getBoolean(PI_WORKBENCH, PREFERENCE_SKIP, PREFERENCE_SKIP_DEFAULT, null)) { | ||
return; | ||
} | ||
Optional<Path> installLocation = getInstallationLocation(); | ||
if (installLocation.isPresent()) { | ||
String checkedPath = getPreference(ConfigurationScope.INSTANCE) | ||
.get(WINDOWS_DEFENDER_CHECKED_INSTALLATION_PATH, ""); //$NON-NLS-1$ | ||
if (!checkedPath.isBlank() && installLocation.get().equals(Path.of(checkedPath))) { | ||
return; // This installation has already been checked at the current location | ||
} | ||
} | ||
monitor.worked(1); | ||
runExclusionCheck(monitor.split(9), installLocation, USER_SELECTABLE_OPTIONS); | ||
}); | ||
job.setSystem(true); | ||
job.schedule(); | ||
} | ||
} | ||
|
||
public static boolean isRelevant() { | ||
// TODO: enable dev-exclusions | ||
return Platform.OS_WIN32.equals(Platform.getOS()) /* && !Platform.inDevelopmentMode() */; | ||
} | ||
|
||
/** | ||
* Opens the dialog to run the exclusion, regardless of any preference. | ||
* | ||
* @return {@code true} if this installation is now excluded, {@code false} if | ||
* Windows Defender is inactive and null if the process was aborted. | ||
*/ | ||
public static Boolean runCheckEnforced(IProgressMonitor m) throws CoreException { | ||
Optional<Path> installLocation = getInstallationLocation(); | ||
return runExclusionCheck(m, installLocation, HandlingOption.EXECUTE_EXCLUSION); | ||
} | ||
|
||
private static final HandlingOption[] USER_SELECTABLE_OPTIONS = { HandlingOption.EXECUTE_EXCLUSION, | ||
HandlingOption.IGNORE_THIS_INSTALLATION, HandlingOption.IGNORE_ALL_INSTALLATIONS }; | ||
|
||
private enum HandlingOption { | ||
DEFENDER_INACTIVE(""), //$NON-NLS-1$ | ||
EXECUTE_EXCLUSION(WorkbenchMessages.WindowsDefenderConfigurator_performExclusionButtonLabel), | ||
IGNORE_THIS_INSTALLATION(WorkbenchMessages.WindowsDefenderConfigurator_ignoreThisButtonLabel), | ||
IGNORE_ALL_INSTALLATIONS(WorkbenchMessages.WindowsDefenderConfigurator_ignoreAllButtonLabel); | ||
|
||
private final String label; | ||
|
||
private HandlingOption(String label) { | ||
this.label = label; | ||
} | ||
} | ||
|
||
/** | ||
* Performs the exclusion of this installation from Windows defender. | ||
* | ||
* @return {@code true} if this installation is now excluded, {@code false} if | ||
* Windows Defender is inactive and null if the process was aborted. | ||
*/ | ||
private static Boolean runExclusionCheck(IProgressMonitor m, Optional<Path> installLocation, | ||
HandlingOption... availableOptions) throws CoreException, AssertionError { | ||
SubMonitor monitor = SubMonitor.convert(m, 3); | ||
if (!isWindowsDefenderActive(monitor.split(1))) { | ||
return Boolean.FALSE; | ||
} | ||
Bundle bundle = FrameworkUtil.getBundle(WorkbenchPlugin.class); | ||
List<Path> bundleContainers = Arrays.stream(bundle.getBundleContext().getBundles()) | ||
.map(FileLocator::getBundleFileLocation).<File>mapMulti(Optional::ifPresent).map(File::toPath) | ||
.map(Path::getParent).distinct().sorted().toList(); | ||
|
||
HandlingOption decision = askForDefenderHandlingDecision(bundleContainers, availableOptions); | ||
if (decision != null) { | ||
switch (decision) { | ||
case EXECUTE_EXCLUSION -> { | ||
// TODO: show a separate progress-indicate that a user can cancel?! Is this even | ||
// possible since the Admin question blocks the screen. | ||
WindowsDefenderConfigurator.excludeDirectoryFromScanning(bundleContainers, monitor.split(2)); | ||
savePreference(ConfigurationScope.INSTANCE, WINDOWS_DEFENDER_CHECKED_INSTALLATION_PATH, | ||
installLocation.map(Path::toString).orElse("")); //$NON-NLS-1$ | ||
} | ||
case IGNORE_THIS_INSTALLATION -> savePreference(ConfigurationScope.INSTANCE, PREFERENCE_SKIP, "true"); //$NON-NLS-1$ | ||
case IGNORE_ALL_INSTALLATIONS -> savePreference(UserScope.INSTANCE, PREFERENCE_SKIP, "true"); //$NON-NLS-1$ | ||
case DEFENDER_INACTIVE -> throw new AssertionError(); | ||
} | ||
} | ||
return decision == HandlingOption.EXECUTE_EXCLUSION ? Boolean.TRUE : null; | ||
} | ||
|
||
private static HandlingOption askForDefenderHandlingDecision(List<Path> directories, HandlingOption[] options) { | ||
String productName = Platform.getProduct().getName(); | ||
String message = NLS.bind(WorkbenchMessages.WindowsDefenderConfigurator_exclusionActionMessage, productName); | ||
|
||
// Use back-tick characters to split a command over multiple lines: | ||
// https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parsing?view=powershell-7.4#line-continuation | ||
String script = createAddExclusionsPowershellCommand(directories, " `\n "); //$NON-NLS-1$ | ||
return PlatformUI.getWorkbench().getDisplay().syncCall(() -> { | ||
String[] buttons = Arrays.stream(options).map(d -> d.label).toArray(String[]::new); | ||
MessageDialog dialog = new MessageDialog(Display.getCurrent().getActiveShell(), | ||
WorkbenchMessages.WindowsDefenderConfigurator_status, null, message, MessageDialog.INFORMATION, 0, | ||
buttons) { | ||
|
||
@Override | ||
protected Control createCustomArea(Composite parent) { | ||
Button showScriptButton = WidgetFactory.button(SWT.PUSH).create(parent); | ||
GridDataFactory.swtDefaults().align(SWT.END, SWT.FILL).grab(true, false).applyTo(showScriptButton); | ||
|
||
Text scriptText = WidgetFactory.text(SWT.BORDER | SWT.H_SCROLL | SWT.V_SCROLL | SWT.READ_ONLY) | ||
.text(script).create(parent); | ||
GridDataFactory.swtDefaults().align(SWT.FILL, SWT.FILL).grab(true, true) | ||
.hint(SWT.DEFAULT, scriptText.getLineHeight() * (directories.size() + 2)) | ||
.applyTo(scriptText); | ||
|
||
setScriptBlockVisible(scriptText, showScriptButton, false); | ||
showScriptButton.addSelectionListener(SelectionListener.widgetSelectedAdapter(e -> { | ||
setScriptBlockVisible(scriptText, showScriptButton, !scriptText.getVisible()); | ||
getContents().pack(); | ||
})); | ||
return showScriptButton; | ||
} | ||
|
||
private void setScriptBlockVisible(Text text, Button button, boolean visible) { | ||
button.setText(WorkbenchMessages.WindowsDefenderConfigurator_scriptButtonLabel | ||
+ (visible ? " <<" : " >>")); //$NON-NLS-1$//$NON-NLS-2$ | ||
text.setVisible(visible); | ||
((GridData) text.getLayoutData()).exclude = !visible; | ||
text.pack(); | ||
text.requestLayout(); | ||
} | ||
|
||
}; | ||
int open = dialog.open(); | ||
return open > -1 ? options[open] : null; | ||
}); | ||
} | ||
|
||
public static IEclipsePreferences getPreference(IScopeContext instance) { | ||
return instance.getNode(PI_WORKBENCH); | ||
} | ||
|
||
public static void savePreference(IScopeContext scope, String key, String value) throws CoreException { | ||
IEclipsePreferences preferences = getPreference(scope); | ||
preferences.put(key, value); | ||
try { | ||
preferences.flush(); | ||
} catch (BackingStoreException e) { | ||
throw new CoreException(Status.error("Failed to safe preference " + preferences, e)); //$NON-NLS-1$ | ||
} | ||
} | ||
|
||
private static Optional<Path> getInstallationLocation() { | ||
// For install location see: | ||
// https://help.eclipse.org/latest/index.jsp?topic=%2Forg.eclipse.platform.doc.isv%2Freference%2Fmisc%2Fruntime-options.html&anchor=locations | ||
try { | ||
Location installLocation = Platform.getConfigurationLocation(); | ||
return Optional.of(Path.of(installLocation.getURL().toURI())); // assume location has a file-URL | ||
} catch (URISyntaxException e) { // ignore | ||
} | ||
return Optional.empty(); | ||
} | ||
|
||
private static boolean isWindowsDefenderActive(IProgressMonitor monitor) throws CoreException { | ||
// https://learn.microsoft.com/en-us/powershell/module/defender/get-mpcomputerstatus | ||
List<String> command = List.of("powershell.exe", "-Command", "(Get-MpComputerStatus).AMRunningMode"); //$NON-NLS-1$//$NON-NLS-2$//$NON-NLS-3$ | ||
try { | ||
List<String> lines = runProcess(command, monitor); | ||
String onlyLine = lines.size() == 1 ? lines.get(0) : ""; //$NON-NLS-1$ | ||
return switch (onlyLine) { | ||
// Known values as listed in | ||
// https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/microsoft-defender-antivirus-windows#use-powershell-to-check-the-status-of-microsoft-defender-antivirus | ||
case "SxS Passive Mode", "Passive mode" -> false; //$NON-NLS-1$ //$NON-NLS-2$ | ||
case "Normal", "EDR Block Mode" -> true; //$NON-NLS-1$//$NON-NLS-2$ | ||
default -> throw new IOException("Process terminated with unexpected result:\n" + String.join("\n", lines)); //$NON-NLS-1$//$NON-NLS-2$ | ||
}; | ||
} catch (IOException e) { | ||
throw new CoreException(Status.error(WorkbenchMessages.WindowsDefenderConfigurator_statusCheckFailed, e)); | ||
} | ||
} | ||
|
||
private static String createAddExclusionsPowershellCommand(Collection<Path> paths, String extraSeparator) { | ||
// For a detailed explanation on how to read excluded paths and to exclude a | ||
// path from being scanned see | ||
// https://learn.microsoft.com/en-us/powershell/module/defender/add-mppreference#-exclusionpath | ||
// https://learn.microsoft.com/en-us/powershell/module/defender/get-mppreference | ||
// For .NET's stream API called LINQ see: | ||
// https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable | ||
String excludedPaths = paths.stream().map(Path::toString).map(p -> '"' + p + '"') | ||
.collect(Collectors.joining(',' + extraSeparator)); | ||
return Stream.of("$exclusions=@(" + extraSeparator + excludedPaths + ')', //$NON-NLS-1$ | ||
"$existingExclusions=[Collections.Generic.HashSet[String]](Get-MpPreference).ExclusionPath", //$NON-NLS-1$ | ||
"$exclusionsToAdd=[Linq.Enumerable]::ToArray([Linq.Enumerable]::Where($exclusions,[Func[object,bool]]{param($ex)!$existingExclusions.Contains($ex)}))", //$NON-NLS-1$ | ||
"if($exclusionsToAdd.Length -gt 0){ Add-MpPreference -ExclusionPath $exclusionsToAdd }") //$NON-NLS-1$ | ||
.collect(Collectors.joining(';' + extraSeparator)); | ||
} | ||
|
||
private static void excludeDirectoryFromScanning(Collection<Path> paths, IProgressMonitor monitor) | ||
throws CoreException { | ||
String exclusionsCommand = createAddExclusionsPowershellCommand(paths, ""); //$NON-NLS-1$ | ||
// In order to change the Windows Defender configuration we need a powershell | ||
// with Administrator privileges and therefore from a basic powershell we start | ||
// a second one with elevated right and run the add-exclusions-command. | ||
// For a detailed explanation of the Start-process parameters see | ||
// https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process#parameters | ||
// Because quoting is difficult when passing a command through multiple | ||
// process-calls/command line processors, the command is passed as | ||
// base64-encoded string to the elevated second powershell. | ||
// For details about the -EncodedCommand argument see (and the EXAMPLES section) | ||
// https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe#-encodedcommand-base64encodedcommand | ||
String encodedCommand = Base64.getEncoder() | ||
.encodeToString(exclusionsCommand.getBytes(StandardCharsets.UTF_16LE)); // encoding is specified | ||
List<String> command = List.of("powershell.exe", //$NON-NLS-1$ | ||
// Launch child powershell with administrator privileges | ||
"Start-Process", "powershell", "-Verb", "RunAs", "-Wait", //$NON-NLS-1$//$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ | ||
"-ArgumentList", "'-EncodedCommand " + encodedCommand + "'"); //$NON-NLS-1$//$NON-NLS-2$//$NON-NLS-3$ | ||
try { | ||
runProcess(command, monitor); | ||
} catch (IOException e) { | ||
throw new CoreException(Status.error(WorkbenchMessages.WindowsDefenderConfigurator_exclusionFailed, e)); | ||
} | ||
} | ||
|
||
private static List<String> runProcess(List<String> command, IProgressMonitor monitor) throws IOException { | ||
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor(); | ||
Process process = new ProcessBuilder(command).start(); | ||
Future<List<String>> processLines = newSingleThreadExecutor.submit(() -> { | ||
try (BufferedReader inputReader = process.inputReader();) { | ||
return inputReader.lines().filter(l -> !l.isBlank()).map(String::strip).toList(); | ||
} | ||
}); | ||
newSingleThreadExecutor.shutdown(); | ||
try { | ||
while (!processLines.isDone()) { | ||
if (monitor.isCanceled()) { | ||
process.destroy(); | ||
process.descendants().forEach(ProcessHandle::destroy); | ||
processLines.cancel(true); | ||
throw new OperationCanceledException(); | ||
} | ||
Thread.onSpinWait(); | ||
Thread.sleep(5); | ||
} | ||
if (process.isAlive()) { | ||
process.destroyForcibly(); | ||
process.descendants().forEach(ProcessHandle::destroyForcibly); | ||
throw new IOException("Process timed-out and it was attempted to forcefully termiante it"); //$NON-NLS-1$ | ||
} else if (process.exitValue() != 0) { | ||
throw new IOException("Process failed with exit-code " + process.exitValue()); //$NON-NLS-1$ | ||
} | ||
return processLines.get(); | ||
} catch (InterruptedException e) { | ||
Thread.currentThread().interrupt(); | ||
throw new OperationCanceledException(); | ||
} catch (ExecutionException e) { | ||
throw new IOException(e.getCause()); | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.