Skip to content

Commit

Permalink
Implement Windows Defender Auto-fix
Browse files Browse the repository at this point in the history
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
HannesWell committed Jan 16, 2024
1 parent e82fd5d commit f91fa77
Show file tree
Hide file tree
Showing 6 changed files with 451 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
dsVersion=V1_3
dsVersion=V1_4
eclipse.preferences.version=1
enabled=true
generateBundleActivationPolicyLazy=true
Expand Down
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());
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,18 @@ public class WorkbenchMessages extends NLS {
public static String CheckedTreeSelectionDialog_select_all;
public static String CheckedTreeSelectionDialog_deselect_all;

public static String WindowsDefenderConfigurator_statusCheck;
public static String WindowsDefenderConfigurator_status;
public static String WindowsDefenderConfigurator_exclusionActionMessage;
public static String WindowsDefenderConfigurator_scriptButtonLabel;
public static String WindowsDefenderConfigurator_performExclusionButtonLabel;
public static String WindowsDefenderConfigurator_ignoreThisButtonLabel;
public static String WindowsDefenderConfigurator_ignoreAllButtonLabel;
public static String WindowsDefenderConfigurator_runExclusionFromPreferenceButtonLabel;
public static String WindowsDefenderConfigurator_statusInactive;
public static String WindowsDefenderConfigurator_statusCheckFailed;
public static String WindowsDefenderConfigurator_exclusionFailed;

// ==============================================================================
// Editor Framework
// ==============================================================================
Expand Down
Loading

0 comments on commit f91fa77

Please sign in to comment.