diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTApiDAO.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTApiDAO.java
index 25e264e49a0e0d025753afee41ef672919b3bfdb..c358feec61a460b49c71addeeb741237198a26f6 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTApiDAO.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTApiDAO.java
@@ -27,7 +27,9 @@
 import com.basistech.df.cybertriage.autopsy.ctapi.json.FileReputationRequest;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.LicenseRequest;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.LicenseResponse;
+import com.basistech.df.cybertriage.autopsy.ctapi.json.MetadataUploadRequest;
 import com.basistech.df.cybertriage.autopsy.ctapi.util.CTHostIDGenerationUtil;
+import java.io.InputStream;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -45,6 +47,8 @@ public class CTApiDAO {
     private static final String LICENSE_REQUEST_PATH = "/_ah/api/license/v1/activate";
     private static final String AUTH_TOKEN_REQUEST_PATH = "/_ah/api/auth/v2/generate_token";
     private static final String CTCLOUD_SERVER_HASH_PATH = "/_ah/api/reputation/v1/query/file/hash/md5?query_types=CORRELATION,MALWARE";
+    private static final String CTCLOUD_UPLOAD_FILE_METADATA_PATH = "/_ah/api/reputation/v1/upload/meta";
+
     private static final String AUTOPSY_PRODUCT = "AUTOPSY";
 
     private static final CTApiDAO instance = new CTApiDAO();
@@ -74,15 +78,27 @@ public LicenseResponse getLicenseInfo(String licenseString) throws CTCloudExcept
     }
 
     public AuthTokenResponse getAuthToken(DecryptedLicenseResponse decrypted) throws CTCloudException {
+        return getAuthToken(decrypted, false);
+    }
+
+    public AuthTokenResponse getAuthToken(DecryptedLicenseResponse decrypted, boolean fileUpload) throws CTCloudException {
         AuthTokenRequest authTokenRequest = new AuthTokenRequest()
                 .setAutopsyVersion(getAppVersion())
-                .setRequestFileUpload(false)
+                .setRequestFileUpload(fileUpload)
                 .setBoostLicenseId(decrypted.getBoostLicenseId())
                 .setHostId(decrypted.getLicenseHostId());
 
         return httpClient.doPost(AUTH_TOKEN_REQUEST_PATH, authTokenRequest, AuthTokenResponse.class);
     }
 
+    public void uploadFile(String url, String fileName, InputStream fileIs) throws CTCloudException {
+        httpClient.doFileUploadPost(url, fileName, fileIs);
+    }
+    
+    public void uploadMeta(AuthenticatedRequestData authenticatedRequestData, MetadataUploadRequest metaRequest) throws CTCloudException {
+        httpClient.doPost(CTCLOUD_UPLOAD_FILE_METADATA_PATH, getAuthParams(authenticatedRequestData), metaRequest, null);
+    }
+
     private static Map<String, String> getAuthParams(AuthenticatedRequestData authenticatedRequestData) {
         return new HashMap<String, String>() {
             {
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudException.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudException.java
index 52d586ee5366add0266082c26129c9ee208fce46..95605c4bcee07cd28a31c0ff5a4cce2cb84f8e7a 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudException.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudException.java
@@ -76,8 +76,7 @@ public ErrorCode getErrorCode() {
 
     public String getErrorDetails() {
         if(getErrorCode() == CTCloudException.ErrorCode.UNKNOWN && Objects.nonNull(getCause())){
-            return String.format("Malware scan error %s occurred. Please try \"Re Scan\" from the dashboard to attempt Malware scaning again. "
-                    + "\nPlease contact Basis support at %s for help if the problem presists.",
+            return String.format("An API error %s occurred. Please try again, and contact Basis support at %s for help if the problem persists.",
                     StringUtils.isNotBlank(getCause().getLocalizedMessage()) ? "("+getCause().getLocalizedMessage()+")": "(Unknown)",
                     Constants.SUPPORT_AT_CYBERTRIAGE_DOT_COM );
         }else {
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java
index 9d4b189ee9720faa9d0f4e9a8ebbca5127111dbc..0a280c87e89bc5f11b52e9f42930620bddf33e2b 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java
@@ -21,42 +21,37 @@
 import com.basistech.df.cybertriage.autopsy.ctapi.util.ObjectMapperUtil;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.IOException;
-import java.net.Authenticator;
-import java.net.InetAddress;
-import java.net.PasswordAuthentication;
+import java.io.InputStream;
+import java.net.Proxy;
+import java.net.ProxySelector;
+import java.net.SocketAddress;
 import java.net.URI;
 import java.net.URISyntaxException;
-import java.net.UnknownHostException;
 import java.security.KeyManagementException;
+import java.security.KeyStore;
 import java.security.KeyStoreException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
 import java.security.UnrecoverableKeyException;
 import java.text.MessageFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
-import java.util.Objects;
 import java.util.logging.Level;
+import java.util.stream.Stream;
 import javax.net.ssl.KeyManager;
 import javax.net.ssl.KeyManagerFactory;
 import javax.net.ssl.SSLContext;
 import javax.net.ssl.TrustManager;
 import javax.net.ssl.TrustManagerFactory;
 import javax.net.ssl.X509TrustManager;
-import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.collections4.MapUtils;
+import org.apache.commons.lang.ArrayUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.http.HttpEntity;
-import org.apache.http.HttpHost;
 import org.apache.http.HttpStatus;
-import org.apache.http.auth.AuthScope;
-import org.apache.http.auth.NTCredentials;
-import org.apache.http.client.CredentialsProvider;
-import org.apache.http.client.config.AuthSchemes;
 import org.apache.http.client.config.RequestConfig;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpPost;
@@ -64,102 +59,83 @@
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.util.EntityUtils;
 import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.entity.ContentType;
 import org.apache.http.entity.StringEntity;
+import org.apache.http.entity.mime.MultipartEntityBuilder;
 import org.sleuthkit.autopsy.coreutils.Logger;
 import org.apache.http.impl.client.HttpClientBuilder;
 import org.apache.http.impl.client.HttpClients;
-import org.apache.http.impl.client.SystemDefaultCredentialsProvider;
 import org.apache.http.impl.client.WinHttpClients;
+import org.apache.http.impl.conn.SystemDefaultRoutePlanner;
+import org.apache.http.ssl.SSLInitializationException;
+import org.netbeans.core.ProxySettings;
+import org.openide.util.Lookup;
 import org.sleuthkit.autopsy.coreutils.Version;
 
 /**
  * Makes the http requests to CT cloud.
+ *
+ * NOTE: regarding proxy settings, the host and port are handled by the
+ * NbProxySelector. Any proxy authentication is handled by NbAuthenticator which
+ * is installed at startup (i.e. NbAuthenticator.install). See
+ * GeneralOptionsModel.testHttpConnection to see how the general options panel
+ * tests the connection.
  */
-public class CTCloudHttpClient {
+class CTCloudHttpClient {
 
-    private static final CTCloudHttpClient instance = new CTCloudHttpClient();
     private static final Logger LOGGER = Logger.getLogger(CTCloudHttpClient.class.getName());
     private static final String HOST_URL = Version.getBuildType() == Version.Type.RELEASE ? Constants.CT_CLOUD_SERVER : Constants.CT_CLOUD_DEV_SERVER;
-
-    private static final List<String> DEFAULT_SCHEME_PRIORITY
-            = new ArrayList<>(Arrays.asList(
-                    AuthSchemes.SPNEGO,
-                    AuthSchemes.KERBEROS,
-                    AuthSchemes.NTLM,
-                    AuthSchemes.CREDSSP,
-                    AuthSchemes.DIGEST,
-                    AuthSchemes.BASIC));
+    private static final String NB_PROXY_SELECTOR_NAME = "org.netbeans.core.NbProxySelector";
 
     private static final int CONNECTION_TIMEOUT_MS = 58 * 1000; // milli sec
 
+    private static final CTCloudHttpClient instance = new CTCloudHttpClient();
+
     public static CTCloudHttpClient getInstance() {
         return instance;
     }
 
     private final ObjectMapper mapper = ObjectMapperUtil.getInstance().getDefaultObjectMapper();
     private final SSLContext sslContext;
-    private String hostName = null;
+    private final ProxySelector proxySelector;
 
     private CTCloudHttpClient() {
         // leave as null for now unless we want to customize this at a later date
-        this.sslContext = null;
+        this.sslContext = createSSLContext();
+        this.proxySelector = getProxySelector();
     }
 
-    private ProxySettingArgs getProxySettings() {
-        if (StringUtils.isBlank(hostName)) {
-            try {
-                hostName = InetAddress.getLocalHost().getCanonicalHostName();
-            } catch (UnknownHostException ex) {
-                LOGGER.log(Level.WARNING, "An error occurred while fetching the hostname", ex);
-            }
-        }
+    private static URI getUri(String host, String path, Map<String, String> urlReqParams) throws URISyntaxException {
+        String url = host + path;
+        URIBuilder builder = new URIBuilder(url);
 
-        int proxyPort = 0;
-        if (StringUtils.isNotBlank(ProxySettings.getHttpPort())) {
-            try {
-                proxyPort = Integer.parseInt(ProxySettings.getHttpsPort());
-            } catch (NumberFormatException ex) {
-                LOGGER.log(Level.WARNING, "Unable to convert port to integer");
+        if (!MapUtils.isEmpty(urlReqParams)) {
+            for (Entry<String, String> e : urlReqParams.entrySet()) {
+                String key = e.getKey();
+                String value = e.getValue();
+                if (StringUtils.isNotBlank(key) || StringUtils.isNotBlank(value)) {
+                    builder.addParameter(key, value);
+                }
             }
         }
 
-        return new ProxySettingArgs(
-                ProxySettings.getProxyType() != ProxySettings.DIRECT_CONNECTION,
-                hostName,
-                ProxySettings.getHttpsHost(),
-                proxyPort,
-                ProxySettings.getAuthenticationUsername(),
-                ProxySettings.getAuthenticationPassword(),
-                null
-        );
+        return builder.build();
     }
-    
+
     public <O> O doPost(String urlPath, Object jsonBody, Class<O> classType) throws CTCloudException {
         return doPost(urlPath, Collections.emptyMap(), jsonBody, classType);
     }
 
     public <O> O doPost(String urlPath, Map<String, String> urlReqParams, Object jsonBody, Class<O> classType) throws CTCloudException {
-        String url = HOST_URL + urlPath;
-        try {
 
+        URI postURI = null;
+        try {
+            postURI = getUri(HOST_URL, urlPath, urlReqParams);
             LOGGER.log(Level.INFO, "initiating http connection to ctcloud server");
-            try (CloseableHttpClient httpclient = createConnection(getProxySettings(), sslContext)) {
-                URIBuilder builder = new URIBuilder(url);
-                
-                if (!MapUtils.isEmpty(urlReqParams)) {
-                    for (Entry<String, String> e : urlReqParams.entrySet()) {
-                        String key = e.getKey();
-                        String value = e.getValue();
-                        if (StringUtils.isNotBlank(key) || StringUtils.isNotBlank(value)) {
-                            builder.addParameter(key, value);
-                        }
-                    }
-                }
+            try (CloseableHttpClient httpclient = createConnection(proxySelector, sslContext)) {
 
-                URI postURI = builder.build();
                 HttpPost postRequest = new HttpPost(postURI);
 
-                
                 configureRequestTimeout(postRequest);
                 postRequest.setHeader("Content-type", "application/json");
 
@@ -177,10 +153,14 @@ public <O> O doPost(String urlPath, Map<String, String> urlReqParams, Object jso
                     if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                         LOGGER.log(Level.INFO, "Response Received. - Status OK");
                         // Parse Response
-                        HttpEntity entity = response.getEntity();
-                        String entityStr = EntityUtils.toString(entity);
-                        O respObj = mapper.readValue(entityStr, classType);
-                        return respObj;
+                        if (classType != null) {
+                            HttpEntity entity = response.getEntity();
+                            String entityStr = EntityUtils.toString(entity);
+                            O respObj = mapper.readValue(entityStr, classType);
+                            return respObj;
+                        } else {
+                            return null;
+                        }
                     } else {
                         LOGGER.log(Level.WARNING, "Response Received. - Status Error {}", response.getStatusLine());
                         handleNonOKResponse(response, "");
@@ -191,16 +171,64 @@ public <O> O doPost(String urlPath, Map<String, String> urlReqParams, Object jso
                 }
             }
         } catch (IOException ex) {
-            LOGGER.log(Level.WARNING, "IO Exception raised when connecting to  CT Cloud using " + url, ex);
+            LOGGER.log(Level.WARNING, "IO Exception raised when connecting to  CT Cloud using " + postURI, ex);
+            throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR, ex);
+        } catch (SSLInitializationException ex) {
+            LOGGER.log(Level.WARNING, "No such algorithm exception raised when creating SSL connection for  CT Cloud using " + postURI, ex);
             throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR, ex);
         } catch (URISyntaxException ex) {
-            LOGGER.log(Level.WARNING, "Wrong URL syntax for CT Cloud " + url, ex);
+            LOGGER.log(Level.WARNING, "Wrong URL syntax for CT Cloud " + postURI, ex);
             throw new CTCloudException(CTCloudException.ErrorCode.UNKNOWN, ex);
         }
 
         return null;
     }
 
+    public void doFileUploadPost(String fullUrlPath, String fileName, InputStream fileIs) throws CTCloudException {
+        URI postUri;
+        try {
+            postUri = new URI(fullUrlPath);
+        } catch (URISyntaxException ex) {
+            LOGGER.log(Level.WARNING, "Wrong URL syntax for CT Cloud " + fullUrlPath, ex);
+            throw new CTCloudException(CTCloudException.ErrorCode.UNKNOWN, ex);
+        }
+
+        try (CloseableHttpClient httpclient = createConnection(proxySelector, sslContext)) {
+            LOGGER.log(Level.INFO, "initiating http post request to ctcloud server " + fullUrlPath);
+            HttpPost post = new HttpPost(postUri);
+            configureRequestTimeout(post);
+
+            post.addHeader("Connection", "keep-alive");
+
+            MultipartEntityBuilder builder = MultipartEntityBuilder.create();
+            builder.addBinaryBody(
+                    "file",
+                    fileIs,
+                    ContentType.APPLICATION_OCTET_STREAM,
+                    fileName
+            );
+
+            HttpEntity multipart = builder.build();
+            post.setEntity(multipart);
+
+            try (CloseableHttpResponse response = httpclient.execute(post)) {
+                int statusCode = response.getStatusLine().getStatusCode();
+                if (statusCode == HttpStatus.SC_OK || statusCode == HttpStatus.SC_NO_CONTENT) {
+                    LOGGER.log(Level.INFO, "Response Received. - Status OK");
+                } else {
+                    LOGGER.log(Level.WARNING, MessageFormat.format("Response Received. - Status Error {0}", response.getStatusLine()));
+                    handleNonOKResponse(response, fileName);
+                }
+            }
+        } catch (SSLInitializationException ex) {
+            LOGGER.log(Level.WARNING, "SSL exception raised when connecting to Reversing Labs for file content upload ", ex);
+            throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR, ex);
+        } catch (IOException ex) {
+            LOGGER.log(Level.WARNING, "IO Exception raised when connecting to Reversing Labs for file content upload ", ex);
+            throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR, ex);
+        }
+    }
+
     /**
      * A generic way to handle the HTTP response - when the response code is NOT
      * 200 OK.
@@ -263,147 +291,140 @@ private void configureRequestTimeout(HttpRequestBase request) {
     }
 
     /**
-     * Creates a connection to CT Cloud with the given arguments.
-     * @param proxySettings The network proxy settings.
-     * @param sslContext The ssl context or null.
-     * @return The connection to CT Cloud.
+     * Get ProxySelector present (favoring NbProxySelector if present).
+     *
+     * @return The found ProxySelector or null.
      */
-    private static CloseableHttpClient createConnection(ProxySettingArgs proxySettings, SSLContext sslContext) {
-        HttpClientBuilder builder = getHttpClientBuilder(proxySettings);
-
-        if (sslContext != null) {
-            builder.setSSLContext(sslContext);
-        }
-        return builder.build();
+    private static ProxySelector getProxySelector() {
+        Collection<? extends ProxySelector> selectors = Lookup.getDefault().lookupAll(ProxySelector.class);
+        return (selectors != null ? selectors.stream() : Stream.empty())
+                .filter(s -> s != null)
+                .map(s -> (ProxySelector) s)
+                .sorted((a, b) -> {
+                    String aName = a.getClass().getCanonicalName();
+                    String bName = b.getClass().getCanonicalName();
+                    boolean aIsNb = aName.equalsIgnoreCase(NB_PROXY_SELECTOR_NAME);
+                    boolean bIsNb = bName.equalsIgnoreCase(NB_PROXY_SELECTOR_NAME);
+                    if (aIsNb == bIsNb) {
+                        return StringUtils.compareIgnoreCase(aName, bName);
+                    } else {
+                        return aIsNb ? -1 : 1;
+                    }
+                })
+                .findFirst()
+                // TODO take this out to remove proxy selector logging
+                .map(s -> new LoggingProxySelector(s))
+                .orElse(null);
     }
 
-    private static HttpClientBuilder getHttpClientBuilder(ProxySettingArgs proxySettings) {
-
-        if (proxySettings.isSystemOrManualProxy()) {
-
-            Authenticator.setDefault(new Authenticator() {
-                @Override
-                protected PasswordAuthentication getPasswordAuthentication() {
-                    LOGGER.info("Requesting Password Authentication...");
-                    return super.getPasswordAuthentication();
-                }
-            });
-
-            HttpClientBuilder builder = null;
-            HttpHost proxyHost = null;
-            CredentialsProvider proxyCredsProvider = null;
-            RequestConfig config = null;
+    /**
+     * Create an SSLContext object using our in-memory keystore.
+     *
+     * @return
+     */
+    private static SSLContext createSSLContext() {
+        LOGGER.log(Level.INFO, "Creating custom SSL context");
+        try {
 
-            if (Objects.nonNull(proxySettings.getProxyHostname()) && proxySettings.getProxyPort() > 0) {
-                proxyHost = new HttpHost(proxySettings.getProxyHostname(), proxySettings.getProxyPort());
+            // I'm not sure how much of this is really necessary to set up, but it works
+            SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
+            KeyManager[] keyManagers = getKeyManagers();
+            TrustManager[] trustManagers = getTrustManagers();
+            sslContext.init(keyManagers, trustManagers, new SecureRandom());
+            return sslContext;
+        } catch (NoSuchAlgorithmException | KeyManagementException ex) {
+            LOGGER.log(Level.SEVERE, "Error creating SSL context", ex);
+            return null;
+        }
+    }
 
-                proxyCredsProvider = getProxyCredentialsProvider(proxySettings);
-                if (StringUtils.isNotBlank(proxySettings.getAuthScheme())) {
-                    if (!DEFAULT_SCHEME_PRIORITY.get(0).equalsIgnoreCase(proxySettings.getAuthScheme())) {
-                        DEFAULT_SCHEME_PRIORITY.removeIf(s -> s.equalsIgnoreCase(proxySettings.getAuthScheme()));
-                        DEFAULT_SCHEME_PRIORITY.add(0, proxySettings.getAuthScheme());
-                    }
-                }
-                config = RequestConfig.custom().setProxyPreferredAuthSchemes(DEFAULT_SCHEME_PRIORITY).build();
-            }
+    // jvm default key manager
+    // based in part on this: https://stackoverflow.com/questions/1793979/registering-multiple-keystores-in-jvm/16229909
+    private static KeyManager[] getKeyManagers() {
+        LOGGER.log(Level.INFO, "Using default algorithm to create trust store: " + KeyManagerFactory.getDefaultAlgorithm());
+        try {
+            KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+            kmf.init(null, null);
+            return kmf.getKeyManagers();
+        } catch (NoSuchAlgorithmException | KeyStoreException | UnrecoverableKeyException ex) {
+            LOGGER.log(Level.SEVERE, "Error getting KeyManagers", ex);
+            return new KeyManager[0];
+        }
 
-            if (Objects.isNull(proxyCredsProvider) && WinHttpClients.isWinAuthAvailable()) {
-                builder = WinHttpClients.custom();
-                builder.useSystemProperties();
-                LOGGER.log(Level.WARNING, "Using Win HTTP Client");
-            } else {
-                builder = HttpClients.custom();
-                builder.setDefaultRequestConfig(config);
-                if (Objects.nonNull(proxyCredsProvider)) { // make sure non null proxycreds before setting it 
-                    builder.setDefaultCredentialsProvider(proxyCredsProvider);
-                }
-                LOGGER.log(Level.WARNING, "Using default http client");
-            }
-            if (Objects.nonNull(proxyHost)) {
-                builder.setProxy(proxyHost);
-                LOGGER.log(Level.WARNING, MessageFormat.format("Using proxy {0}", proxyHost));
-            }
+    }
 
-            return builder;
-        } else {
-            return HttpClients.custom();
+    // jvm default trust store
+    // based in part on this: https://stackoverflow.com/questions/1793979/registering-multiple-keystores-in-jvm/16229909
+    private static TrustManager[] getTrustManagers() {
+        try {
+            LOGGER.log(Level.INFO, "Using default algorithm to create trust store: " + TrustManagerFactory.getDefaultAlgorithm());
+            TrustManagerFactory tmf
+                    = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+            tmf.init((KeyStore) null);
+            X509TrustManager tm = (X509TrustManager) tmf.getTrustManagers()[0];
+
+            return new TrustManager[]{tm};
+        } catch (KeyStoreException | NoSuchAlgorithmException ex) {
+            LOGGER.log(Level.SEVERE, "Error getting TrustManager", ex);
+            return new TrustManager[0];
         }
     }
 
     /**
-     * Returns a CredentialsProvider for proxy, if one is configured.
+     * Creates a connection to CT Cloud with the given arguments.
      *
-     * @return CredentialsProvider, if a proxy is configured with credentials,
-     * null otherwise
+     * @param proxySelector The proxy selector.
+     * @param sslContext The ssl context or null.
+     * @return The connection to CT Cloud.
      */
-    private static CredentialsProvider getProxyCredentialsProvider(ProxySettingArgs proxySettings) {
-        CredentialsProvider proxyCredsProvider = null;
-        if (proxySettings.isSystemOrManualProxy()) {
-            if (StringUtils.isNotBlank(proxySettings.getProxyUserId())) {
-                if (null != proxySettings.getProxyPassword() && proxySettings.getProxyPassword().length > 0) { // Password will be blank for KERBEROS / NEGOTIATE schemes.
-                    proxyCredsProvider = new SystemDefaultCredentialsProvider();
-                    String userId = proxySettings.getProxyUserId();
-                    String domain = null;
-                    if (userId.contains("\\")) {
-                        domain = userId.split("\\\\")[0];
-                        userId = userId.split("\\\\")[1];
-                    }
-                    String workStation = proxySettings.getHostName();
-                    proxyCredsProvider.setCredentials(new AuthScope(proxySettings.getProxyHostname(), proxySettings.getProxyPort()),
-                            new NTCredentials(userId, new String(proxySettings.getProxyPassword()), workStation, domain));
-                }
-            }
-        }
+    private static CloseableHttpClient createConnection(ProxySelector proxySelector, SSLContext sslContext) throws SSLInitializationException {
+        HttpClientBuilder builder;
 
-        return proxyCredsProvider;
-    }
+        if (ProxySettings.getProxyType() != ProxySettings.DIRECT_CONNECTION
+                && StringUtils.isBlank(ProxySettings.getAuthenticationUsername()) 
+                && ArrayUtils.isEmpty(ProxySettings.getAuthenticationPassword())
+                && WinHttpClients.isWinAuthAvailable()) {
 
-    private static class ProxySettingArgs {
-
-        private final boolean systemOrManualProxy;
-        private final String hostName;
-        private final String proxyHostname;
-        private final int proxyPort;
-        private final String proxyUserId;
-        private final char[] proxyPassword;
-        private final String authScheme;
-
-        ProxySettingArgs(boolean systemOrManualProxy, String hostName, String proxyHostname, int proxyPort, String proxyUserId, char[] proxyPassword, String authScheme) {
-            this.systemOrManualProxy = systemOrManualProxy;
-            this.hostName = hostName;
-            this.proxyHostname = proxyHostname;
-            this.proxyPort = proxyPort;
-            this.proxyUserId = proxyUserId;
-            this.proxyPassword = proxyPassword;
-            this.authScheme = authScheme;
+            builder = WinHttpClients.custom();
+            builder.useSystemProperties();
+            LOGGER.log(Level.WARNING, "Using Win HTTP Client");
+        } else {
+            builder = HttpClients.custom();
+            // builder.setDefaultRequestConfig(config);
+            LOGGER.log(Level.WARNING, "Using default http client");
         }
 
-        boolean isSystemOrManualProxy() {
-            return systemOrManualProxy;
+        if (sslContext != null) {
+            builder.setSSLContext(sslContext);
         }
 
-        String getHostName() {
-            return hostName;
+        if (proxySelector != null) {
+            builder.setRoutePlanner(new SystemDefaultRoutePlanner(proxySelector));
         }
 
-        String getProxyHostname() {
-            return proxyHostname;
-        }
+        return builder.build();
+    }
 
-        int getProxyPort() {
-            return proxyPort;
-        }
+    private static class LoggingProxySelector extends ProxySelector {
 
-        String getProxyUserId() {
-            return proxyUserId;
+        private final ProxySelector delegate;
+
+        public LoggingProxySelector(ProxySelector delegate) {
+            this.delegate = delegate;
         }
 
-        char[] getProxyPassword() {
-            return proxyPassword;
+        @Override
+        public List<Proxy> select(URI uri) {
+            List<Proxy> selectedProxies = delegate.select(uri);
+            LOGGER.log(Level.INFO, MessageFormat.format("Proxy selected for {0} are {1}", uri, selectedProxies));
+            return selectedProxies;
         }
 
-        public String getAuthScheme() {
-            return authScheme;
+        @Override
+        public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
+            LOGGER.log(Level.WARNING, MessageFormat.format("Connection failed connecting to {0} socket address {1}", uri, sa), ioe);
+            delegate.connectFailed(uri, sa, ioe);
         }
+
     }
 }
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/Constants.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/Constants.java
index 9587b3cd44dbf4984aac10a9b5639661e5f4a883..8290d6621d0e05f1e6cf12c24c5813539dc837b3 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/Constants.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/Constants.java
@@ -23,7 +23,7 @@
 /**
  * Constants regarding connections to cyber triage cloud.
  */
-final public class Constants {
+final class Constants {
 
     public static final String CYBER_TRIAGE = "CyberTriage";
 
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/ProxySettings.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/ProxySettings.java
deleted file mode 100644
index f710a6ab1bb1641d4f890a70de926e93647bc021..0000000000000000000000000000000000000000
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/ProxySettings.java
+++ /dev/null
@@ -1,446 +0,0 @@
-/*
- * Autopsy Forensic Browser
- *
- * Copyright 2023 Basis Technology Corp.
- * Contact: carrier <at> sleuthkit <dot> org
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.basistech.df.cybertriage.autopsy.ctapi;
-
-import java.net.*;
-import java.util.*;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import java.util.prefs.PreferenceChangeListener;
-import java.util.prefs.Preferences;
-import org.netbeans.api.keyring.Keyring;
-import org.openide.util.*;
-import org.openide.util.lookup.ServiceProvider;
-
-/**
- * Taken from https://raw.githubusercontent.com/apache/netbeans/master/platform/o.n.core/src/org/netbeans/core/ProxySettings.java
- * @author Jiri Rechtacek
- */
-public class ProxySettings {
-    
-    public static final String PROXY_HTTP_HOST = "proxyHttpHost";   // NOI18N
-    public static final String PROXY_HTTP_PORT = "proxyHttpPort";   // NOI18N
-    public static final String PROXY_HTTPS_HOST = "proxyHttpsHost"; // NOI18N
-    public static final String PROXY_HTTPS_PORT = "proxyHttpsPort"; // NOI18N
-    public static final String PROXY_SOCKS_HOST = "proxySocksHost"; // NOI18N
-    public static final String PROXY_SOCKS_PORT = "proxySocksPort"; // NOI18N
-    public static final String NOT_PROXY_HOSTS = "proxyNonProxyHosts";  // NOI18N
-    public static final String PROXY_TYPE = "proxyType";                // NOI18N
-    public static final String USE_PROXY_AUTHENTICATION = "useProxyAuthentication"; // NOI18N
-    public static final String PROXY_AUTHENTICATION_USERNAME = "proxyAuthenticationUsername";   // NOI18N
-    public static final String PROXY_AUTHENTICATION_PASSWORD = "proxyAuthenticationPassword";   // NOI18N
-    public static final String USE_PROXY_ALL_PROTOCOLS = "useProxyAllProtocols";    // NOI18N
-    public static final String DIRECT = "DIRECT";   // NOI18N
-    public static final String PAC = "PAC";     // NOI18N
-    
-    public static final String SYSTEM_PROXY_HTTP_HOST = "systemProxyHttpHost";      // NOI18N
-    public static final String SYSTEM_PROXY_HTTP_PORT = "systemProxyHttpPort";      // NOI18N
-    public static final String SYSTEM_PROXY_HTTPS_HOST = "systemProxyHttpsHost";    // NOI18N
-    public static final String SYSTEM_PROXY_HTTPS_PORT = "systemProxyHttpsPort";    // NOI18N
-    public static final String SYSTEM_PROXY_SOCKS_HOST = "systemProxySocksHost";    // NOI18N
-    public static final String SYSTEM_PROXY_SOCKS_PORT = "systemProxySocksPort";    // NOI18N
-    public static final String SYSTEM_NON_PROXY_HOSTS = "systemProxyNonProxyHosts"; // NOI18N
-    public static final String SYSTEM_PAC = "systemPAC";                            // NOI18N
-    
-    // Only for testing purpose (Test connection in General options panel)
-    public static final String TEST_SYSTEM_PROXY_HTTP_HOST = "testSystemProxyHttpHost"; // NOI18N
-    public static final String TEST_SYSTEM_PROXY_HTTP_PORT = "testSystemProxyHttpPort"; // NOI18N
-    public static final String HTTP_CONNECTION_TEST_URL = "https://netbeans.apache.org";// NOI18N
-    
-    private static String presetNonProxyHosts;
-
-    /** No proxy is used to connect. */
-    public static final int DIRECT_CONNECTION = 0;
-    
-    /** Proxy setting is automatically detect in OS. */
-    public static final int AUTO_DETECT_PROXY = 1; // as default
-    
-    /** Manually set proxy host and port. */
-    public static final int MANUAL_SET_PROXY = 2;
-    
-    /** Proxy PAC file automatically detect in OS. */
-    public static final int AUTO_DETECT_PAC = 3;
-    
-    /** Proxy PAC file manually set. */
-    public static final int MANUAL_SET_PAC = 4;
-    
-    private static final Logger LOGGER = Logger.getLogger(ProxySettings.class.getName());
-    
-    private static Preferences getPreferences() {
-        return NbPreferences.forModule (ProxySettings.class);
-    }
-    
-    
-    public static String getHttpHost () {
-        return normalizeProxyHost (getPreferences ().get (PROXY_HTTP_HOST, ""));
-    }
-    
-    public static String getHttpPort () {
-        return getPreferences ().get (PROXY_HTTP_PORT, "");
-    }
-    
-    public static String getHttpsHost () {
-        if (useProxyAllProtocols ()) {
-            return getHttpHost ();
-        } else {
-            return getPreferences ().get (PROXY_HTTPS_HOST, "");
-        }
-    }
-    
-    public static String getHttpsPort () {
-        if (useProxyAllProtocols ()) {
-            return getHttpPort ();
-        } else {
-            return getPreferences ().get (PROXY_HTTPS_PORT, "");
-        }
-    }
-    
-    public static String getSocksHost () {
-        if (useProxyAllProtocols ()) {
-            return getHttpHost ();
-        } else {
-            return getPreferences ().get (PROXY_SOCKS_HOST, "");
-        }
-    }
-    
-    public static String getSocksPort () {
-        if (useProxyAllProtocols ()) {
-            return getHttpPort ();
-        } else {
-            return getPreferences ().get (PROXY_SOCKS_PORT, "");
-        }
-    }
-    
-    public static String getNonProxyHosts () {
-        String hosts = getPreferences ().get (NOT_PROXY_HOSTS, getDefaultUserNonProxyHosts ());
-        return compactNonProxyHosts(hosts);
-    }
-    
-    public static int getProxyType () {
-        int type = getPreferences ().getInt (PROXY_TYPE, AUTO_DETECT_PROXY);
-        if (AUTO_DETECT_PROXY == type) {
-            type = ProxySettings.getSystemPac() != null ? AUTO_DETECT_PAC : AUTO_DETECT_PROXY;
-        }
-        return type;
-    }
-    
-    
-    public static String getSystemHttpHost() {
-        return getPreferences().get(SYSTEM_PROXY_HTTP_HOST, "");
-    }
-    
-    public static String getSystemHttpPort() {
-        return getPreferences().get(SYSTEM_PROXY_HTTP_PORT, "");
-    }
-    
-    public static String getSystemHttpsHost() {
-        return getPreferences().get(SYSTEM_PROXY_HTTPS_HOST, "");
-    }
-    
-    public static String getSystemHttpsPort() {
-        return getPreferences().get(SYSTEM_PROXY_HTTPS_PORT, "");
-    }
-    
-    public static String getSystemSocksHost() {
-        return getPreferences().get(SYSTEM_PROXY_SOCKS_HOST, "");
-    }
-    
-    public static String getSystemSocksPort() {
-        return getPreferences().get(SYSTEM_PROXY_SOCKS_PORT, "");
-    }
-    
-    public static String getSystemNonProxyHosts() {
-        return getPreferences().get(SYSTEM_NON_PROXY_HOSTS, getModifiedNonProxyHosts(""));
-    }
-    
-    public static String getSystemPac() {
-        return getPreferences().get(SYSTEM_PAC, null);
-    }
-    
-    
-    public static String getTestSystemHttpHost() {
-        return getPreferences().get(TEST_SYSTEM_PROXY_HTTP_HOST, "");
-    }
-    
-    public static String getTestSystemHttpPort() {
-        return getPreferences().get(TEST_SYSTEM_PROXY_HTTP_PORT, "");
-    }
-    
-    
-    public static boolean useAuthentication () {
-        return getPreferences ().getBoolean (USE_PROXY_AUTHENTICATION, false);
-    }
-    
-    public static boolean useProxyAllProtocols () {
-        return getPreferences ().getBoolean (USE_PROXY_ALL_PROTOCOLS, false);
-    }
-    
-    public static String getAuthenticationUsername () {
-        return getPreferences ().get (PROXY_AUTHENTICATION_USERNAME, "");
-    }
-    
-    public static char[] getAuthenticationPassword () {
-        String old = getPreferences().get(PROXY_AUTHENTICATION_PASSWORD, null);
-        if (old != null) {
-            getPreferences().remove(PROXY_AUTHENTICATION_PASSWORD);
-            setAuthenticationPassword(old.toCharArray());
-        }
-        char[] pwd = Keyring.read(PROXY_AUTHENTICATION_PASSWORD);
-        return pwd != null ? pwd : new char[0];
-    }
-    
-    public static void setAuthenticationPassword(char[] password) {
-        Keyring.save(ProxySettings.PROXY_AUTHENTICATION_PASSWORD, password,
-                // XXX consider including getHttpHost and/or getHttpsHost
-                NbBundle.getMessage(ProxySettings.class, "ProxySettings.password.description"));    // NOI18N
-    }
-
-    public static void addPreferenceChangeListener (PreferenceChangeListener l) {
-        getPreferences ().addPreferenceChangeListener (l);
-    }
-    
-    public static void removePreferenceChangeListener (PreferenceChangeListener l) {
-        getPreferences ().removePreferenceChangeListener (l);
-    }
-    
-    private static String getPresetNonProxyHosts () {
-        if (presetNonProxyHosts == null) {
-            presetNonProxyHosts = System.getProperty ("http.nonProxyHosts", "");    // NOI18N
-        }
-        return presetNonProxyHosts;
-    }
-    
-    private static String getDefaultUserNonProxyHosts () {
-        return getModifiedNonProxyHosts (getSystemNonProxyHosts ());
-    }
-
-  
-    private static String concatProxies(String... proxies) {
-        StringBuilder sb = new StringBuilder();
-        for (String n : proxies) {
-            if (n == null) {
-                continue;
-            }
-            n = n.trim();
-            if (n.isEmpty()) {
-                continue;
-            }
-            if (sb.length() > 0 && sb.charAt(sb.length() - 1) != '|') { // NOI18N
-                if (!n.startsWith("|")) {   // NOI18N
-                    sb.append('|');         // NOI18N
-                }
-            }
-            sb.append(n);
-        }
-        return sb.toString();
-    }
-
-    private static String getModifiedNonProxyHosts (String systemPreset) {
-        String fromSystem = systemPreset.replace (";", "|").replace (",", "|"); //NOI18N
-        String fromUser = getPresetNonProxyHosts () == null ? "" : getPresetNonProxyHosts ().replace (";", "|").replace (",", "|"); //NOI18N
-        if (Utilities.isWindows ()) {
-            fromSystem = addReguralToNonProxyHosts (fromSystem);
-        }
-        final String staticNonProxyHosts = NbBundle.getMessage(ProxySettings.class, "StaticNonProxyHosts"); // NOI18N
-        String nonProxy = concatProxies(fromUser, fromSystem, staticNonProxyHosts); // NOI18N
-        String localhost;
-        try {
-            localhost = InetAddress.getLocalHost().getHostName();
-            if (!"localhost".equals(localhost)) { // NOI18N
-                nonProxy = nonProxy + "|" + localhost; // NOI18N
-            } else {
-                // Avoid this error when hostname == localhost:
-                // Error in http.nonProxyHosts system property:  sun.misc.REException: localhost is a duplicate
-            }
-        }
-        catch (UnknownHostException e) {
-            // OK. Sometimes a hostname is assigned by DNS, but a computer
-            // is later pulled off the network. It may then produce a bogus
-            // name for itself which can't actually be resolved. Normally
-            // "localhost" is aliased to 127.0.0.1 anyway.
-        }
-        /* per Milan's agreement it's removed. See issue #89868
-        try {
-            String localhost2 = InetAddress.getLocalHost().getCanonicalHostName();
-            if (!"localhost".equals(localhost2) && !localhost2.equals(localhost)) { // NOI18N
-                nonProxy = nonProxy + "|" + localhost2; // NOI18N
-            } else {
-                // Avoid this error when hostname == localhost:
-                // Error in http.nonProxyHosts system property:  sun.misc.REException: localhost is a duplicate
-            }
-        }
-        catch (UnknownHostException e) {
-            // OK. Sometimes a hostname is assigned by DNS, but a computer
-            // is later pulled off the network. It may then produce a bogus
-            // name for itself which can't actually be resolved. Normally
-            // "localhost" is aliased to 127.0.0.1 anyway.
-        }
-         */
-        return compactNonProxyHosts (nonProxy);
-    }
-
-
-    // avoid duplicate hosts
-    private static String compactNonProxyHosts (String hosts) {
-        StringTokenizer st = new StringTokenizer(hosts, ","); //NOI18N
-        StringBuilder nonProxyHosts = new StringBuilder();
-        while (st.hasMoreTokens()) {
-            String h = st.nextToken().trim();
-            if (h.length() == 0) {
-                continue;
-            }
-            if (nonProxyHosts.length() > 0) {
-                nonProxyHosts.append("|"); // NOI18N
-            }
-            nonProxyHosts.append(h);
-        }
-        st = new StringTokenizer (nonProxyHosts.toString(), "|"); //NOI18N
-        Set<String> set = new HashSet<String> (); 
-        StringBuilder compactedProxyHosts = new StringBuilder();
-        while (st.hasMoreTokens ()) {
-            String t = st.nextToken ();
-            if (set.add (t.toLowerCase (Locale.US))) {
-                if (compactedProxyHosts.length() > 0) {
-                    compactedProxyHosts.append('|');    // NOI18N
-                }
-                compactedProxyHosts.append(t);
-            }
-        }
-        return compactedProxyHosts.toString();
-    }
-    
-    private static String addReguralToNonProxyHosts (String nonProxyHost) {
-        StringTokenizer st = new StringTokenizer (nonProxyHost, "|");   // NOI18N
-        StringBuilder reguralProxyHosts = new StringBuilder();
-        while (st.hasMoreTokens ()) {
-            String t = st.nextToken ();
-            if (t.indexOf ('*') == -1) { //NOI18N
-                t = t + '*'; //NOI18N
-            }
-            if (reguralProxyHosts.length() > 0) 
-                reguralProxyHosts.append('|');  // NOI18N
-            reguralProxyHosts.append(t);
-        }
-
-        return reguralProxyHosts.toString();
-    }
-
-    public static String normalizeProxyHost (String proxyHost) {
-        if (proxyHost.toLowerCase (Locale.US).startsWith ("http://")) { // NOI18N
-            return proxyHost.substring (7, proxyHost.length ());
-        } else {
-            return proxyHost;
-        }
-    }
-    
-    private static InetSocketAddress analyzeProxy(URI uri) {
-        Parameters.notNull("uri", uri);     // NOI18N
-        List<Proxy> proxies = ProxySelector.getDefault().select(uri);
-        assert proxies != null : "ProxySelector cannot return null for " + uri;     // NOI18N
-        assert !proxies.isEmpty() : "ProxySelector cannot return empty list for " + uri;    // NOI18N
-        String protocol = uri.getScheme();
-        Proxy p = proxies.get(0);
-        if (Proxy.Type.DIRECT == p.type()) {
-            // return null for DIRECT proxy
-            return null;
-        }
-        if (protocol == null
-                || ((protocol.startsWith("http") || protocol.equals("ftp")) && Proxy.Type.HTTP == p.type()) // NOI18N
-                || !(protocol.startsWith("http") || protocol.equals("ftp"))) {  // NOI18N
-            if (p.address() instanceof InetSocketAddress) {
-                // check is
-                //assert ! ((InetSocketAddress) p.address()).isUnresolved() : p.address() + " must be resolved address.";
-                return (InetSocketAddress) p.address();
-            } else {
-                LOGGER.log(Level.INFO, p.address() + " is not instanceof InetSocketAddress but " + p.address().getClass()); // NOI18N
-                return null;
-            }
-        } else {
-            return null;
-        }
-    }
-    
-    public static void reload() {
-        Reloader reloader = Lookup.getDefault().lookup(Reloader.class);
-        reloader.reload();
-    }
-
-    @ServiceProvider(service = NetworkSettings.ProxyCredentialsProvider.class, position = 1000)
-    public static class NbProxyCredentialsProvider extends NetworkSettings.ProxyCredentialsProvider {
-
-        @Override
-        public String getProxyHost(URI u) {
-            if (getPreferences() == null) {
-                return null;
-            }
-            InetSocketAddress sa = analyzeProxy(u);
-            return sa == null ? null : sa.getHostName();
-        }
-
-        @Override
-        public String getProxyPort(URI u) {
-            if (getPreferences() == null) {
-                return null;
-            }
-            InetSocketAddress sa = analyzeProxy(u);
-            return sa == null ? null : Integer.toString(sa.getPort());
-        }
-
-        @Override
-        protected String getProxyUserName(URI u) {
-            if (getPreferences() == null) {
-                return null;
-            }
-            return ProxySettings.getAuthenticationUsername();
-        }
-
-        @Override
-        protected char[] getProxyPassword(URI u) {
-            if (getPreferences() == null) {
-                return null;
-            }
-            return ProxySettings.getAuthenticationPassword();
-        }
-
-        @Override
-        protected boolean isProxyAuthentication(URI u) {
-            if (getPreferences() == null) {
-                return false;
-            }
-            return getPreferences().getBoolean(USE_PROXY_AUTHENTICATION, false);
-        }
-
-    }
-    
-    /** A bridge between <code>o.n.core</code> and <code>core.network</code>.
-     * An implementation of this class brings a facility to reload Network Proxy Settings
-     * from underlying OS.
-     * The module <code>core.network</code> provides a implementation which may be accessible
-     * via <code>Lookup.getDefault()</code>. It's not guaranteed any implementation is found on all distribution. 
-     * 
-     * @since 3.40
-     */
-    public abstract static class Reloader {
-        
-        /** Reloads Network Proxy Settings from underlying system.
-         *
-         */
-        public abstract void reload();
-    }
-}
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/DecryptedLicenseResponse.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/DecryptedLicenseResponse.java
index c6f91721ef7d05f679963727e0b787c11b2ea04a..d939d6ed753343a757a0a9c0d55cc324ade23e21 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/DecryptedLicenseResponse.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/DecryptedLicenseResponse.java
@@ -38,7 +38,7 @@ public class DecryptedLicenseResponse {
     private final Long fileUploads;
     private final Instant activationTime;
     private final String product;
-    private final String limitType;
+    private final LicenseLimitType limitType;
     private final String timezone;
     private final String customerEmail;
     private final String customerName;
@@ -54,7 +54,7 @@ public DecryptedLicenseResponse(
             @JsonDeserialize(using = InstantEpochMillisDeserializer.class)
             @JsonProperty("activationTime") Instant activationTime,
             @JsonProperty("product") String product,
-            @JsonProperty("limitType") String limitType,
+            @JsonProperty("limitType") LicenseLimitType limitType,
             @JsonProperty("timezone") String timezone,
             @JsonProperty("customerEmail") String customerEmail,
             @JsonProperty("customerName") String customerName
@@ -96,7 +96,7 @@ public String getProduct() {
         return product;
     }
 
-    public String getLimitType() {
+    public LicenseLimitType getLimitType() {
         return limitType;
     }
 
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/LicenseLimitType.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/LicenseLimitType.java
new file mode 100644
index 0000000000000000000000000000000000000000..7d185f86aef9aabdbe2e13c41e4730d01b0bcd8e
--- /dev/null
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/LicenseLimitType.java
@@ -0,0 +1,30 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2023 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.basistech.df.cybertriage.autopsy.ctapi.json;
+
+/**
+ * The limit type (and reset) for the license.
+ */
+public enum LicenseLimitType {
+    HOURLY,
+    DAILY,
+    WEEKLY,
+    MONTHLY,
+    NO_RESET;
+}
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/MetadataUploadRequest.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/MetadataUploadRequest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7d7e1e7f6473e7f405368fa1f6671d72806f71a9
--- /dev/null
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/MetadataUploadRequest.java
@@ -0,0 +1,109 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2023 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.basistech.df.cybertriage.autopsy.ctapi.json;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class MetadataUploadRequest {
+
+    @JsonProperty("file_upload_url")
+    private String fileUploadUrl;
+
+    @JsonProperty("sha1")
+    private String sha1;
+
+    @JsonProperty("sha256")
+    private String sha256;
+
+    @JsonProperty("md5")
+    private String md5;
+
+    @JsonProperty("filePath")
+    private String filePath;
+
+    @JsonProperty("fileSize")
+    private Long fileSizeBytes;
+
+    @JsonProperty("createdDate")
+    private Long createdDate;
+
+    public String getFileUploadUrl() {
+        return fileUploadUrl;
+    }
+
+    public MetadataUploadRequest setFileUploadUrl(String fileUploadUrl) {
+        this.fileUploadUrl = fileUploadUrl;
+        return this;
+    }
+
+    public String getSha1() {
+        return sha1;
+    }
+
+    public MetadataUploadRequest setSha1(String sha1) {
+        this.sha1 = sha1;
+        return this;
+    }
+
+    public String getSha256() {
+        return sha256;
+    }
+
+    public MetadataUploadRequest setSha256(String sha256) {
+        this.sha256 = sha256;
+        return this;
+    }
+
+    public String getMd5() {
+        return md5;
+    }
+
+    public MetadataUploadRequest setMd5(String md5) {
+        this.md5 = md5;
+        return this;
+    }
+
+    public String getFilePath() {
+        return filePath;
+    }
+
+    public MetadataUploadRequest setFilePath(String filePath) {
+        this.filePath = filePath;
+        return this;
+    }
+
+    public Long getFileSizeBytes() {
+        return fileSizeBytes;
+    }
+
+    public MetadataUploadRequest setFileSizeBytes(Long fileSizeBytes) {
+        this.fileSizeBytes = fileSizeBytes;
+        return this;
+    }
+
+    public Long getCreatedDate() {
+        return createdDate;
+    }
+
+    public MetadataUploadRequest setCreatedDate(Long createdDate) {
+        this.createdDate = createdDate;
+        return this;
+    }
+
+}
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties
index 46f62fdf7a5185355907a8f9980343d9478c03de..840725f5d73abfaaf90261560679ed393718505c 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties
@@ -3,7 +3,7 @@
 # Click nbfs://nbhost/SystemFileSystem/Templates/Other/properties.properties to edit this template
 OptionsCategory_Name_CyberTriage=Cyber Triage
 OptionsCategory_Keywords_CyberTriage=Cyber Triage,Cyber,Triage
-LicenseDisclaimerPanel.disclaimer.text=<html>The Cyber Triage Malware Scanner module uses 40+ malware scanning engines to identify if Windows executables are malicious. It requires a non-free license to use.</html>
+LicenseDisclaimerPanel.disclaimer.text=<html>The Cyber Triage Malware Scanner module uses 40+ malware scanning engines to identify if Windows executables are malicious. It requires a paid subscription to use.</html>
 LicenseDisclaimerPanel.purchaseFromLabel.text=You can purchase a license from
-LicenseDisclaimerPanel.link.text=<html><span style="color: blue; text-decoration: underline">https://cybertriage.com/autopsy-checkout</span></html>
 LicenseDisclaimerPanel.border.title=Disclaimer
+LicenseDisclaimerPanel.trialLabel.text=You can try a free 7-day trial from
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties-MERGED b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties-MERGED
index 46f62fdf7a5185355907a8f9980343d9478c03de..840725f5d73abfaaf90261560679ed393718505c 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties-MERGED
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties-MERGED
@@ -3,7 +3,7 @@
 # Click nbfs://nbhost/SystemFileSystem/Templates/Other/properties.properties to edit this template
 OptionsCategory_Name_CyberTriage=Cyber Triage
 OptionsCategory_Keywords_CyberTriage=Cyber Triage,Cyber,Triage
-LicenseDisclaimerPanel.disclaimer.text=<html>The Cyber Triage Malware Scanner module uses 40+ malware scanning engines to identify if Windows executables are malicious. It requires a non-free license to use.</html>
+LicenseDisclaimerPanel.disclaimer.text=<html>The Cyber Triage Malware Scanner module uses 40+ malware scanning engines to identify if Windows executables are malicious. It requires a paid subscription to use.</html>
 LicenseDisclaimerPanel.purchaseFromLabel.text=You can purchase a license from
-LicenseDisclaimerPanel.link.text=<html><span style="color: blue; text-decoration: underline">https://cybertriage.com/autopsy-checkout</span></html>
 LicenseDisclaimerPanel.border.title=Disclaimer
+LicenseDisclaimerPanel.trialLabel.text=You can try a free 7-day trial from
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/LicenseDisclaimerPanel.form b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/LicenseDisclaimerPanel.form
index 83e3c8440aa0228fbbdc4166d41dc85f34df8eb0..a88404c592a200dacad0601d41bd8ceec4d8c3f1 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/LicenseDisclaimerPanel.form
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/LicenseDisclaimerPanel.form
@@ -6,17 +6,18 @@
       <Border info="org.netbeans.modules.form.compat2.border.TitledBorderInfo">
         <TitledBorder title="Disclaimer">
           <ResourceString PropertyName="titleX" bundle="com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties" key="LicenseDisclaimerPanel.border.title" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+          <Color PropertyName="color" blue="0" green="0" red="ff" type="rgb"/>
         </TitledBorder>
       </Border>
     </Property>
     <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
-      <Dimension value="[2147483647, 90]"/>
+      <Dimension value="[2147483647, 108]"/>
     </Property>
     <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
-      <Dimension value="[562, 90]"/>
+      <Dimension value="[562, 108]"/>
     </Property>
     <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
-      <Dimension value="[400, 90]"/>
+      <Dimension value="[400, 108]"/>
     </Property>
   </Properties>
   <AuxValues>
@@ -29,7 +30,7 @@
     <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
     <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
     <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
-    <AuxValue name="designerSize" type="java.awt.Dimension" value="-84,-19,0,5,115,114,0,18,106,97,118,97,46,97,119,116,46,68,105,109,101,110,115,105,111,110,65,-114,-39,-41,-84,95,68,20,2,0,2,73,0,6,104,101,105,103,104,116,73,0,5,119,105,100,116,104,120,112,0,0,0,90,0,0,2,50"/>
+    <AuxValue name="designerSize" type="java.awt.Dimension" value="-84,-19,0,5,115,114,0,18,106,97,118,97,46,97,119,116,46,68,105,109,101,110,115,105,111,110,65,-114,-39,-41,-84,95,68,20,2,0,2,73,0,6,104,101,105,103,104,116,73,0,5,119,105,100,116,104,120,112,0,0,0,108,0,0,2,50"/>
   </AuxValues>
 
   <Layout class="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout"/>
@@ -47,71 +48,139 @@
       </AuxValues>
       <Constraints>
         <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
-          <GridBagConstraints gridX="0" gridY="0" gridWidth="2" gridHeight="1" fill="2" ipadX="0" ipadY="0" insetsTop="5" insetsLeft="5" insetsBottom="5" insetsRight="5" anchor="18" weightX="1.0" weightY="0.0"/>
+          <GridBagConstraints gridX="0" gridY="0" gridWidth="1" gridHeight="1" fill="2" ipadX="0" ipadY="0" insetsTop="5" insetsLeft="5" insetsBottom="5" insetsRight="5" anchor="18" weightX="1.0" weightY="0.0"/>
         </Constraint>
       </Constraints>
     </Component>
-    <Component class="javax.swing.JLabel" name="purchaseFromLabel">
-      <Properties>
-        <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
-          <ResourceString bundle="com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties" key="LicenseDisclaimerPanel.purchaseFromLabel.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
-        </Property>
-      </Properties>
+    <Container class="javax.swing.JPanel" name="spacer">
       <AuxValues>
         <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
         <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
       </AuxValues>
       <Constraints>
         <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
-          <GridBagConstraints gridX="-1" gridY="1" gridWidth="1" gridHeight="1" fill="0" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="5" insetsBottom="5" insetsRight="3" anchor="18" weightX="0.0" weightY="0.0"/>
+          <GridBagConstraints gridX="0" gridY="3" gridWidth="1" gridHeight="1" fill="0" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="0" insetsBottom="0" insetsRight="0" anchor="10" weightX="0.0" weightY="1.0"/>
         </Constraint>
       </Constraints>
-    </Component>
-    <Component class="javax.swing.JLabel" name="link">
-      <Properties>
-        <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
-          <ResourceString bundle="com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties" key="LicenseDisclaimerPanel.link.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
-        </Property>
-        <Property name="cursor" type="java.awt.Cursor" editor="org.netbeans.modules.form.editors2.CursorEditor">
-          <Color id="Hand Cursor"/>
-        </Property>
-      </Properties>
-      <Events>
-        <EventHandler event="mouseClicked" listener="java.awt.event.MouseListener" parameters="java.awt.event.MouseEvent" handler="linkMouseClicked"/>
-      </Events>
+
+      <Layout>
+        <DimensionLayout dim="0">
+          <Group type="103" groupAlignment="0" attributes="0">
+              <EmptySpace min="0" pref="0" max="32767" attributes="0"/>
+          </Group>
+        </DimensionLayout>
+        <DimensionLayout dim="1">
+          <Group type="103" groupAlignment="0" attributes="0">
+              <EmptySpace min="0" pref="0" max="32767" attributes="0"/>
+          </Group>
+        </DimensionLayout>
+      </Layout>
+    </Container>
+    <Container class="javax.swing.JPanel" name="trialPanel">
       <AuxValues>
         <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
         <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
       </AuxValues>
       <Constraints>
         <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
-          <GridBagConstraints gridX="-1" gridY="1" gridWidth="1" gridHeight="1" fill="0" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="0" insetsBottom="5" insetsRight="5" anchor="18" weightX="1.0" weightY="0.0"/>
+          <GridBagConstraints gridX="0" gridY="1" gridWidth="1" gridHeight="1" fill="2" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="5" insetsBottom="5" insetsRight="5" anchor="10" weightX="1.0" weightY="0.0"/>
         </Constraint>
       </Constraints>
-    </Component>
-    <Container class="javax.swing.JPanel" name="spacer">
+
+      <Layout class="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout"/>
+      <SubComponents>
+        <Component class="javax.swing.JLabel" name="trialLabel">
+          <Properties>
+            <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+              <ResourceString bundle="com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties" key="LicenseDisclaimerPanel.trialLabel.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+            </Property>
+          </Properties>
+          <AuxValues>
+            <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+            <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+          </AuxValues>
+          <Constraints>
+            <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
+              <GridBagConstraints gridX="0" gridY="0" gridWidth="1" gridHeight="1" fill="0" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="0" insetsBottom="0" insetsRight="0" anchor="18" weightX="0.0" weightY="0.0"/>
+            </Constraint>
+          </Constraints>
+        </Component>
+        <Component class="javax.swing.JLabel" name="trialLink">
+          <Properties>
+            <Property name="text" type="java.lang.String" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
+              <Connection code="getHtmlLink(TRIAL_URL)" type="code"/>
+            </Property>
+            <Property name="cursor" type="java.awt.Cursor" editor="org.netbeans.modules.form.editors2.CursorEditor">
+              <Color id="Hand Cursor"/>
+            </Property>
+          </Properties>
+          <Events>
+            <EventHandler event="mouseClicked" listener="java.awt.event.MouseListener" parameters="java.awt.event.MouseEvent" handler="trialLinkMouseClicked"/>
+          </Events>
+          <AuxValues>
+            <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+            <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+          </AuxValues>
+          <Constraints>
+            <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
+              <GridBagConstraints gridX="1" gridY="0" gridWidth="1" gridHeight="1" fill="0" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="3" insetsBottom="0" insetsRight="0" anchor="18" weightX="1.0" weightY="0.0"/>
+            </Constraint>
+          </Constraints>
+        </Component>
+      </SubComponents>
+    </Container>
+    <Container class="javax.swing.JPanel" name="purchasePanel">
       <AuxValues>
         <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
         <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
       </AuxValues>
       <Constraints>
         <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
-          <GridBagConstraints gridX="0" gridY="2" gridWidth="1" gridHeight="1" fill="0" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="0" insetsBottom="0" insetsRight="0" anchor="10" weightX="0.0" weightY="1.0"/>
+          <GridBagConstraints gridX="0" gridY="2" gridWidth="1" gridHeight="1" fill="2" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="5" insetsBottom="5" insetsRight="5" anchor="10" weightX="1.0" weightY="0.0"/>
         </Constraint>
       </Constraints>
 
-      <Layout>
-        <DimensionLayout dim="0">
-          <Group type="103" groupAlignment="0" attributes="0">
-              <EmptySpace min="0" pref="0" max="32767" attributes="0"/>
-          </Group>
-        </DimensionLayout>
-        <DimensionLayout dim="1">
-          <Group type="103" groupAlignment="0" attributes="0">
-              <EmptySpace min="0" pref="0" max="32767" attributes="0"/>
-          </Group>
-        </DimensionLayout>
-      </Layout>
+      <Layout class="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout"/>
+      <SubComponents>
+        <Component class="javax.swing.JLabel" name="purchaseFromLabel">
+          <Properties>
+            <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+              <ResourceString bundle="com/basistech/df/cybertriage/autopsy/ctoptions/Bundle.properties" key="LicenseDisclaimerPanel.purchaseFromLabel.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+            </Property>
+          </Properties>
+          <AuxValues>
+            <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+            <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+          </AuxValues>
+          <Constraints>
+            <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
+              <GridBagConstraints gridX="0" gridY="0" gridWidth="1" gridHeight="1" fill="0" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="0" insetsBottom="0" insetsRight="0" anchor="18" weightX="0.0" weightY="0.0"/>
+            </Constraint>
+          </Constraints>
+        </Component>
+        <Component class="javax.swing.JLabel" name="purchaseLink">
+          <Properties>
+            <Property name="text" type="java.lang.String" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
+              <Connection code="getHtmlLink(PURCHASE_URL)" type="code"/>
+            </Property>
+            <Property name="cursor" type="java.awt.Cursor" editor="org.netbeans.modules.form.editors2.CursorEditor">
+              <Color id="Hand Cursor"/>
+            </Property>
+          </Properties>
+          <Events>
+            <EventHandler event="mouseClicked" listener="java.awt.event.MouseListener" parameters="java.awt.event.MouseEvent" handler="purchaseLinkMouseClicked"/>
+          </Events>
+          <AuxValues>
+            <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+            <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+          </AuxValues>
+          <Constraints>
+            <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
+              <GridBagConstraints gridX="1" gridY="0" gridWidth="1" gridHeight="1" fill="0" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="3" insetsBottom="0" insetsRight="0" anchor="18" weightX="1.0" weightY="0.0"/>
+            </Constraint>
+          </Constraints>
+        </Component>
+      </SubComponents>
     </Container>
   </SubComponents>
 </Form>
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/LicenseDisclaimerPanel.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/LicenseDisclaimerPanel.java
index 4299f02d620e69d06770ed0e5b98a326c58069fa..328a252467778a4307ae83cf9e060e4b0212c3b5 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/LicenseDisclaimerPanel.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/LicenseDisclaimerPanel.java
@@ -32,7 +32,12 @@ public class LicenseDisclaimerPanel extends javax.swing.JPanel {
 
     private static final Logger LOGGER = Logger.getLogger(LicenseDisclaimerPanel.class.getName());
 
-    private static final String CHECKOUT_PAGE_URL = "https://cybertriage.com/autopsy-checkout";
+    private static final String TRIAL_URL = "https://cybertriage.com/autopsy-trial";
+    private static final String PURCHASE_URL = "https://cybertriage.com/autopsy-checkout";
+
+    private static String getHtmlLink(String url) {
+        return "<html><span style=\"color: blue; text-decoration: underline\">" + url + "</span></html>";
+    }
 
     /**
      * Creates new form LicenseDisclaimerPanel
@@ -52,14 +57,18 @@ private void initComponents() {
         java.awt.GridBagConstraints gridBagConstraints;
 
         javax.swing.JLabel disclaimer = new javax.swing.JLabel();
-        javax.swing.JLabel purchaseFromLabel = new javax.swing.JLabel();
-        javax.swing.JLabel link = new javax.swing.JLabel();
         javax.swing.JPanel spacer = new javax.swing.JPanel();
+        javax.swing.JPanel trialPanel = new javax.swing.JPanel();
+        javax.swing.JLabel trialLabel = new javax.swing.JLabel();
+        javax.swing.JLabel trialLink = new javax.swing.JLabel();
+        javax.swing.JPanel purchasePanel = new javax.swing.JPanel();
+        javax.swing.JLabel purchaseFromLabel = new javax.swing.JLabel();
+        javax.swing.JLabel purchaseLink = new javax.swing.JLabel();
 
-        setBorder(javax.swing.BorderFactory.createTitledBorder(org.openide.util.NbBundle.getMessage(LicenseDisclaimerPanel.class, "LicenseDisclaimerPanel.border.title"))); // NOI18N
-        setMaximumSize(new java.awt.Dimension(2147483647, 90));
-        setMinimumSize(new java.awt.Dimension(562, 90));
-        setPreferredSize(new java.awt.Dimension(400, 90));
+        setBorder(javax.swing.BorderFactory.createTitledBorder(null, org.openide.util.NbBundle.getMessage(LicenseDisclaimerPanel.class, "LicenseDisclaimerPanel.border.title"), javax.swing.border.TitledBorder.DEFAULT_JUSTIFICATION, javax.swing.border.TitledBorder.DEFAULT_POSITION, new java.awt.Font("Segoe UI", 0, 12), new java.awt.Color(255, 0, 0))); // NOI18N
+        setMaximumSize(new java.awt.Dimension(2147483647, 108));
+        setMinimumSize(new java.awt.Dimension(562, 108));
+        setPreferredSize(new java.awt.Dimension(400, 108));
         setLayout(new java.awt.GridBagLayout());
 
         org.openide.awt.Mnemonics.setLocalizedText(disclaimer, org.openide.util.NbBundle.getMessage(LicenseDisclaimerPanel.class, "LicenseDisclaimerPanel.disclaimer.text")); // NOI18N
@@ -67,34 +76,12 @@ private void initComponents() {
         gridBagConstraints = new java.awt.GridBagConstraints();
         gridBagConstraints.gridx = 0;
         gridBagConstraints.gridy = 0;
-        gridBagConstraints.gridwidth = 2;
         gridBagConstraints.fill = java.awt.GridBagConstraints.HORIZONTAL;
         gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
         gridBagConstraints.weightx = 1.0;
         gridBagConstraints.insets = new java.awt.Insets(5, 5, 5, 5);
         add(disclaimer, gridBagConstraints);
 
-        org.openide.awt.Mnemonics.setLocalizedText(purchaseFromLabel, org.openide.util.NbBundle.getMessage(LicenseDisclaimerPanel.class, "LicenseDisclaimerPanel.purchaseFromLabel.text")); // NOI18N
-        gridBagConstraints = new java.awt.GridBagConstraints();
-        gridBagConstraints.gridy = 1;
-        gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
-        gridBagConstraints.insets = new java.awt.Insets(0, 5, 5, 3);
-        add(purchaseFromLabel, gridBagConstraints);
-
-        org.openide.awt.Mnemonics.setLocalizedText(link, org.openide.util.NbBundle.getMessage(LicenseDisclaimerPanel.class, "LicenseDisclaimerPanel.link.text")); // NOI18N
-        link.setCursor(new java.awt.Cursor(java.awt.Cursor.HAND_CURSOR));
-        link.addMouseListener(new java.awt.event.MouseAdapter() {
-            public void mouseClicked(java.awt.event.MouseEvent evt) {
-                linkMouseClicked(evt);
-            }
-        });
-        gridBagConstraints = new java.awt.GridBagConstraints();
-        gridBagConstraints.gridy = 1;
-        gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
-        gridBagConstraints.weightx = 1.0;
-        gridBagConstraints.insets = new java.awt.Insets(0, 0, 5, 5);
-        add(link, gridBagConstraints);
-
         javax.swing.GroupLayout spacerLayout = new javax.swing.GroupLayout(spacer);
         spacer.setLayout(spacerLayout);
         spacerLayout.setHorizontalGroup(
@@ -108,24 +95,94 @@ public void mouseClicked(java.awt.event.MouseEvent evt) {
 
         gridBagConstraints = new java.awt.GridBagConstraints();
         gridBagConstraints.gridx = 0;
-        gridBagConstraints.gridy = 2;
+        gridBagConstraints.gridy = 3;
         gridBagConstraints.weighty = 1.0;
         add(spacer, gridBagConstraints);
+
+        trialPanel.setLayout(new java.awt.GridBagLayout());
+
+        org.openide.awt.Mnemonics.setLocalizedText(trialLabel, org.openide.util.NbBundle.getMessage(LicenseDisclaimerPanel.class, "LicenseDisclaimerPanel.trialLabel.text")); // NOI18N
+        gridBagConstraints = new java.awt.GridBagConstraints();
+        gridBagConstraints.gridx = 0;
+        gridBagConstraints.gridy = 0;
+        gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
+        trialPanel.add(trialLabel, gridBagConstraints);
+
+        org.openide.awt.Mnemonics.setLocalizedText(trialLink, getHtmlLink(TRIAL_URL));
+        trialLink.setCursor(new java.awt.Cursor(java.awt.Cursor.HAND_CURSOR));
+        trialLink.addMouseListener(new java.awt.event.MouseAdapter() {
+            public void mouseClicked(java.awt.event.MouseEvent evt) {
+                trialLinkMouseClicked(evt);
+            }
+        });
+        gridBagConstraints = new java.awt.GridBagConstraints();
+        gridBagConstraints.gridx = 1;
+        gridBagConstraints.gridy = 0;
+        gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
+        gridBagConstraints.weightx = 1.0;
+        gridBagConstraints.insets = new java.awt.Insets(0, 3, 0, 0);
+        trialPanel.add(trialLink, gridBagConstraints);
+
+        gridBagConstraints = new java.awt.GridBagConstraints();
+        gridBagConstraints.gridx = 0;
+        gridBagConstraints.gridy = 1;
+        gridBagConstraints.fill = java.awt.GridBagConstraints.HORIZONTAL;
+        gridBagConstraints.weightx = 1.0;
+        gridBagConstraints.insets = new java.awt.Insets(0, 5, 5, 5);
+        add(trialPanel, gridBagConstraints);
+
+        purchasePanel.setLayout(new java.awt.GridBagLayout());
+
+        org.openide.awt.Mnemonics.setLocalizedText(purchaseFromLabel, org.openide.util.NbBundle.getMessage(LicenseDisclaimerPanel.class, "LicenseDisclaimerPanel.purchaseFromLabel.text")); // NOI18N
+        gridBagConstraints = new java.awt.GridBagConstraints();
+        gridBagConstraints.gridx = 0;
+        gridBagConstraints.gridy = 0;
+        gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
+        purchasePanel.add(purchaseFromLabel, gridBagConstraints);
+
+        org.openide.awt.Mnemonics.setLocalizedText(purchaseLink, getHtmlLink(PURCHASE_URL));
+        purchaseLink.setCursor(new java.awt.Cursor(java.awt.Cursor.HAND_CURSOR));
+        purchaseLink.addMouseListener(new java.awt.event.MouseAdapter() {
+            public void mouseClicked(java.awt.event.MouseEvent evt) {
+                purchaseLinkMouseClicked(evt);
+            }
+        });
+        gridBagConstraints = new java.awt.GridBagConstraints();
+        gridBagConstraints.gridx = 1;
+        gridBagConstraints.gridy = 0;
+        gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
+        gridBagConstraints.weightx = 1.0;
+        gridBagConstraints.insets = new java.awt.Insets(0, 3, 0, 0);
+        purchasePanel.add(purchaseLink, gridBagConstraints);
+
+        gridBagConstraints = new java.awt.GridBagConstraints();
+        gridBagConstraints.gridx = 0;
+        gridBagConstraints.gridy = 2;
+        gridBagConstraints.fill = java.awt.GridBagConstraints.HORIZONTAL;
+        gridBagConstraints.weightx = 1.0;
+        gridBagConstraints.insets = new java.awt.Insets(0, 5, 5, 5);
+        add(purchasePanel, gridBagConstraints);
     }// </editor-fold>//GEN-END:initComponents
 
-    private void linkMouseClicked(java.awt.event.MouseEvent evt) {//GEN-FIRST:event_linkMouseClicked
+    private void purchaseLinkMouseClicked(java.awt.event.MouseEvent evt) {//GEN-FIRST:event_purchaseLinkMouseClicked
+        gotoLink(PURCHASE_URL);
+    }//GEN-LAST:event_purchaseLinkMouseClicked
+
+    private void trialLinkMouseClicked(java.awt.event.MouseEvent evt) {//GEN-FIRST:event_trialLinkMouseClicked
+        gotoLink(TRIAL_URL);
+    }//GEN-LAST:event_trialLinkMouseClicked
+
+    private void gotoLink(String url) {
         if (Desktop.isDesktopSupported()) {
             try {
-                Desktop.getDesktop().browse(new URI(CHECKOUT_PAGE_URL));
+                Desktop.getDesktop().browse(new URI(url));
             } catch (IOException | URISyntaxException e) {
-                LOGGER.log(Level.SEVERE, "Error opening link to: " + CHECKOUT_PAGE_URL, e);
+                LOGGER.log(Level.SEVERE, "Error opening link to: " + url, e);
             }
         } else {
             LOGGER.log(Level.WARNING, "Desktop API is not supported.  Link cannot be opened.");
         }
-    }//GEN-LAST:event_linkMouseClicked
-
-
+    }
     // Variables declaration - do not modify//GEN-BEGIN:variables
     // End of variables declaration//GEN-END:variables
 }
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties
index bd06716288dd34cc480b2c4ae540220ef0714b54..d0395d6a1af9b3129c4a10a0feab3230af9304a1 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties
@@ -24,3 +24,5 @@ CTMalwareScannerOptionsPanel.licenseInfoUserLabel.text=
 EULADialog.cancelButton.text=Cancel
 EULADialog.acceptButton.text=Accept
 EULADialog.title=Cyber Triage End User License Agreement
+CTMalwareScannerOptionsPanel.fileUploadCheckbox.text=Upload file if hash lookup produces no results
+CTMalwareScannerOptionsPanel.fileUploadPanel.border.title=File Upload
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties-MERGED b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties-MERGED
index 58a1befb1fec3d8c24c56663ea033db027b5158c..18e60839f3f3f38106cff8b0507a623af5221106 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties-MERGED
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties-MERGED
@@ -22,6 +22,10 @@ CTMalwareScannerOptionsPanel.licenseInfoIdLabel.text=
 CTMalwareScannerOptionsPanel.licenseInfoExpiresLabel.text=
 CTMalwareScannerOptionsPanel.fileUploadsRemainingLabel.text=
 CTMalwareScannerOptionsPanel.licenseInfoUserLabel.text=
+CTMalwareScannerOptionsPanel_getResetSuffix_daily=/day
+CTMalwareScannerOptionsPanel_getResetSuffix_hourly=/hour
+CTMalwareScannerOptionsPanel_getResetSuffix_monthly=/month
+CTMalwareScannerOptionsPanel_getResetSuffix_weekly=/week
 CTMalwareScannerOptionsPanel_licenseAddDialog_desc=License Number:
 CTMalwareScannerOptionsPanel_licenseAddDialog_title=Add a License...
 CTMalwareScannerOptionsPanel_licenseAddDialogEnteredErr_desc=The license number has already been entered
@@ -45,9 +49,11 @@ CTMalwareScannerOptionsPanel_malwareScans_fileUploadsRemaining=File uploads rema
 # {0} - hashLookupsRemaining
 CTMalwareScannerOptionsPanel_malwareScans_hashLookupsRemaining=Hash lookups remaining: {0}
 # {0} - maxDailyFileLookups
-CTMalwareScannerOptionsPanel_malwareScans_maxDailyFileLookups=Max file uploads: {0}/day
+# {1} - resetSuffix
+CTMalwareScannerOptionsPanel_malwareScans_maxDailyFileLookups=Max file uploads: {0}{1}
 # {0} - maxDailyLookups
-CTMalwareScannerOptionsPanel_malwareScans_maxDailyHashLookups=Max Hash lookups: {0}/day
+# {1} - resetSuffix
+CTMalwareScannerOptionsPanel_malwareScans_maxDailyHashLookups=Max Hash lookups: {0}{1}
 CTMalwareScannerOptionsPanel_MalwareScansFetcher_apiErr_title=Server Error
 CTMalwareScannerOptionsPanel_MalwareScansFetcher_localErr_desc=A general error occurred while fetching malware scans information.  Please try again later.
 CTMalwareScannerOptionsPanel_MalwareScansFetcher_localErr_title=General Error
@@ -56,3 +62,5 @@ CTOPtionsPanel_loadMalwareScansInfo_loading=Loading...
 EULADialog.cancelButton.text=Cancel
 EULADialog.acceptButton.text=Accept
 EULADialog.title=Cyber Triage End User License Agreement
+CTMalwareScannerOptionsPanel.fileUploadCheckbox.text=Upload file if hash lookup produces no results
+CTMalwareScannerOptionsPanel.fileUploadPanel.border.title=File Upload
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTLicenseDialog.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTLicenseDialog.java
index 608ea6304006f80b9a14299975a12140e2cf7d6c..3bff998901d0fc071686150e58c3eedf4c8d57df 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTLicenseDialog.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTLicenseDialog.java
@@ -27,7 +27,7 @@
 /**
  * License dialog
  */
-public class CTLicenseDialog extends javax.swing.JDialog {
+class CTLicenseDialog extends javax.swing.JDialog {
 
     private static final Pattern LICENSE_PATTERN = Pattern.compile("^\\s*[a-zA-Z0-9\\-]+?\\s*$");
     private String licenseString = null;
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTLicensePersistence.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTLicensePersistence.java
index 9f7f3b8bf6c9211f227045fbeb9c94ca09dec7db..717c4f4321348b6cb7a55c98dcb0245a7bddc95a 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTLicensePersistence.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTLicensePersistence.java
@@ -26,7 +26,6 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.File;
 import java.io.IOException;
-import java.nio.file.Files;
 import java.nio.file.Paths;
 import java.util.Optional;
 import java.util.logging.Level;
@@ -40,6 +39,7 @@ public class CTLicensePersistence {
 
     private static final String CT_SETTINGS_DIR = "CyberTriage";
     private static final String CT_LICENSE_FILENAME = "CyberTriageLicense.json";
+    private static final String MALWARE_INGEST_SETTINGS_FILENAME = "MalwareIngestSettings.json";
 
     private static final Logger logger = Logger.getLogger(CTLicensePersistence.class.getName());
 
@@ -91,7 +91,44 @@ public synchronized Optional<LicenseInfo> loadLicenseInfo() {
         });
     }
 
+    public synchronized boolean saveMalwareSettings(MalwareIngestSettings malwareIngestSettings) {
+        if (malwareIngestSettings != null) {
+            File settingsFile = getMalwareIngestFile();
+            try {
+                settingsFile.getParentFile().mkdirs();
+                objectMapper.writeValue(settingsFile, malwareIngestSettings);
+                return true;
+            } catch (IOException ex) {
+                logger.log(Level.WARNING, "There was an error writing malware ingest settings to file: " + settingsFile.getAbsolutePath(), ex);
+            }
+        }
+
+        return false;
+    }
+
+    public synchronized MalwareIngestSettings loadMalwareIngestSettings() {
+        MalwareIngestSettings settings = null;
+        File settingsFile = getMalwareIngestFile();
+        if (settingsFile.exists() && settingsFile.isFile()) {
+            try {
+                settings = objectMapper.readValue(settingsFile, MalwareIngestSettings.class);
+            } catch (IOException ex) {
+                logger.log(Level.WARNING, "There was an error reading malware ingest settings from file: " + settingsFile.getAbsolutePath(), ex);
+            }
+        }
+        
+        if (settings == null) {
+            settings = new MalwareIngestSettings();
+        }
+
+        return settings;
+    }
+
     private File getCTLicenseFile() {
         return Paths.get(PlatformUtil.getModuleConfigDirectory(), CT_SETTINGS_DIR, CT_LICENSE_FILENAME).toFile();
     }
+
+    private File getMalwareIngestFile() {
+        return Paths.get(PlatformUtil.getModuleConfigDirectory(), CT_SETTINGS_DIR, MALWARE_INGEST_SETTINGS_FILENAME).toFile();
+    }
 }
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.form b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.form
index 77361419b65f0831a21bdd6019e2c7d17309c199..8557f946d95770c3e1462ce2345afdbd4d85f0d5 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.form
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.form
@@ -11,11 +11,54 @@
     <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
     <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
     <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
-    <AuxValue name="designerSize" type="java.awt.Dimension" value="-84,-19,0,5,115,114,0,18,106,97,118,97,46,97,119,116,46,68,105,109,101,110,115,105,111,110,65,-114,-39,-41,-84,95,68,20,2,0,2,73,0,6,104,101,105,103,104,116,73,0,5,119,105,100,116,104,120,112,0,0,0,-109,0,0,1,-29"/>
+    <AuxValue name="designerSize" type="java.awt.Dimension" value="-84,-19,0,5,115,114,0,18,106,97,118,97,46,97,119,116,46,68,105,109,101,110,115,105,111,110,65,-114,-39,-41,-84,95,68,20,2,0,2,73,0,6,104,101,105,103,104,116,73,0,5,119,105,100,116,104,120,112,0,0,0,-69,0,0,1,-29"/>
   </AuxValues>
 
   <Layout class="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout"/>
   <SubComponents>
+    <Container class="javax.swing.JPanel" name="fileUploadPanel">
+      <Properties>
+        <Property name="border" type="javax.swing.border.Border" editor="org.netbeans.modules.form.editors2.BorderEditor">
+          <Border info="org.netbeans.modules.form.compat2.border.TitledBorderInfo">
+            <TitledBorder title="File Upload">
+              <ResourceString PropertyName="titleX" bundle="com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties" key="CTMalwareScannerOptionsPanel.fileUploadPanel.border.title" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+            </TitledBorder>
+          </Border>
+        </Property>
+      </Properties>
+      <AuxValues>
+        <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+        <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+      </AuxValues>
+      <Constraints>
+        <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
+          <GridBagConstraints gridX="0" gridY="0" gridWidth="1" gridHeight="1" fill="2" ipadX="0" ipadY="0" insetsTop="0" insetsLeft="0" insetsBottom="0" insetsRight="0" anchor="18" weightX="1.0" weightY="0.0"/>
+        </Constraint>
+      </Constraints>
+
+      <Layout class="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout"/>
+      <SubComponents>
+        <Component class="javax.swing.JCheckBox" name="fileUploadCheckbox">
+          <Properties>
+            <Property name="selected" type="boolean" value="true"/>
+            <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+              <ResourceString bundle="com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/Bundle.properties" key="CTMalwareScannerOptionsPanel.fileUploadCheckbox.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+            </Property>
+            <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+              <Dimension value="[32767, 20]"/>
+            </Property>
+          </Properties>
+          <Events>
+            <EventHandler event="actionPerformed" listener="java.awt.event.ActionListener" parameters="java.awt.event.ActionEvent" handler="fileUploadCheckboxActionPerformed"/>
+          </Events>
+          <Constraints>
+            <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout" value="org.netbeans.modules.form.compat2.layouts.DesignGridBagLayout$GridBagConstraintsDescription">
+              <GridBagConstraints gridX="0" gridY="0" gridWidth="1" gridHeight="1" fill="2" ipadX="0" ipadY="0" insetsTop="5" insetsLeft="5" insetsBottom="5" insetsRight="5" anchor="18" weightX="1.0" weightY="1.0"/>
+            </Constraint>
+          </Constraints>
+        </Component>
+      </SubComponents>
+    </Container>
     <Container class="javax.swing.JPanel" name="licenseInfoPanel">
       <Properties>
         <Property name="border" type="javax.swing.border.Border" editor="org.netbeans.modules.form.editors2.BorderEditor">
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.java
index 564eeb7ebaa4551baa66808bbe0d6b660b2fedd7..2a09c1ed5518cf3a69d834610c371b93ce16b404 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.java
@@ -24,6 +24,7 @@
 import com.basistech.df.cybertriage.autopsy.ctapi.json.AuthTokenResponse;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.DecryptedLicenseResponse;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.LicenseInfo;
+import com.basistech.df.cybertriage.autopsy.ctapi.json.LicenseLimitType;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.LicenseResponse;
 import com.basistech.df.cybertriage.autopsy.ctapi.util.LicenseDecryptorUtil;
 import com.basistech.df.cybertriage.autopsy.ctapi.util.LicenseDecryptorUtil.InvalidLicenseException;
@@ -33,6 +34,7 @@
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.Optional;
+import java.util.concurrent.CancellationException;
 import java.util.concurrent.ExecutionException;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -112,6 +114,7 @@ public void componentShown(ComponentEvent e) {
     @Override
     public synchronized void saveSettings() {
         ctPersistence.saveLicenseResponse(getLicenseInfo());
+        ctPersistence.saveMalwareSettings(getIngestSettings());
     }
 
     @Override
@@ -128,12 +131,27 @@ public synchronized void loadSettings() {
         if (licenseInfo != null) {
             loadMalwareScansInfo(licenseInfo);
         }
+
+        MalwareIngestSettings ingestSettings = ctPersistence.loadMalwareIngestSettings();
+        setIngestSettings(ingestSettings);
     }
 
     private synchronized LicenseResponse getLicenseInfo() {
         return this.licenseInfo == null ? null : this.licenseInfo.getLicenseResponse();
     }
 
+    private MalwareIngestSettings getIngestSettings() {
+        return new MalwareIngestSettings()
+                .setUploadFiles(this.fileUploadCheckbox.isSelected());
+    }
+
+    private void setIngestSettings(MalwareIngestSettings ingestSettings) {
+        if (ingestSettings == null) {
+            ingestSettings = new MalwareIngestSettings();
+        }
+        this.fileUploadCheckbox.setSelected(ingestSettings.isUploadFiles());
+    }
+
     private synchronized void setLicenseDisplay(LicenseInfo licenseInfo, String licenseMessage) {
         this.licenseInfo = licenseInfo;
         this.licenseInfoMessage = licenseMessage;
@@ -202,6 +220,8 @@ private synchronized void loadMalwareScansInfo(LicenseInfo licenseInfo) {
     private void initComponents() {
         java.awt.GridBagConstraints gridBagConstraints;
 
+        javax.swing.JPanel fileUploadPanel = new javax.swing.JPanel();
+        fileUploadCheckbox = new javax.swing.JCheckBox();
         javax.swing.JPanel licenseInfoPanel = new javax.swing.JPanel();
         licenseInfoMessageLabel = new javax.swing.JLabel();
         licenseInfoUserLabel = new javax.swing.JLabel();
@@ -218,6 +238,35 @@ private void initComponents() {
 
         setLayout(new java.awt.GridBagLayout());
 
+        fileUploadPanel.setBorder(javax.swing.BorderFactory.createTitledBorder(org.openide.util.NbBundle.getMessage(CTMalwareScannerOptionsPanel.class, "CTMalwareScannerOptionsPanel.fileUploadPanel.border.title"))); // NOI18N
+        fileUploadPanel.setLayout(new java.awt.GridBagLayout());
+
+        fileUploadCheckbox.setSelected(true);
+        org.openide.awt.Mnemonics.setLocalizedText(fileUploadCheckbox, org.openide.util.NbBundle.getMessage(CTMalwareScannerOptionsPanel.class, "CTMalwareScannerOptionsPanel.fileUploadCheckbox.text")); // NOI18N
+        fileUploadCheckbox.setMaximumSize(new java.awt.Dimension(32767, 20));
+        fileUploadCheckbox.addActionListener(new java.awt.event.ActionListener() {
+            public void actionPerformed(java.awt.event.ActionEvent evt) {
+                fileUploadCheckboxActionPerformed(evt);
+            }
+        });
+        gridBagConstraints = new java.awt.GridBagConstraints();
+        gridBagConstraints.gridx = 0;
+        gridBagConstraints.gridy = 0;
+        gridBagConstraints.fill = java.awt.GridBagConstraints.HORIZONTAL;
+        gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
+        gridBagConstraints.weightx = 1.0;
+        gridBagConstraints.weighty = 1.0;
+        gridBagConstraints.insets = new java.awt.Insets(5, 5, 5, 5);
+        fileUploadPanel.add(fileUploadCheckbox, gridBagConstraints);
+
+        gridBagConstraints = new java.awt.GridBagConstraints();
+        gridBagConstraints.gridx = 0;
+        gridBagConstraints.gridy = 0;
+        gridBagConstraints.fill = java.awt.GridBagConstraints.HORIZONTAL;
+        gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST;
+        gridBagConstraints.weightx = 1.0;
+        add(fileUploadPanel, gridBagConstraints);
+
         licenseInfoPanel.setBorder(javax.swing.BorderFactory.createTitledBorder(org.openide.util.NbBundle.getMessage(CTMalwareScannerOptionsPanel.class, "CTMalwareScannerOptionsPanel.licenseInfoPanel.border.title"))); // NOI18N
         licenseInfoPanel.setLayout(new java.awt.GridBagLayout());
 
@@ -378,6 +427,10 @@ private void licenseInfoAddButtonActionPerformed(java.awt.event.ActionEvent evt)
         }
     }//GEN-LAST:event_licenseInfoAddButtonActionPerformed
 
+    private void fileUploadCheckboxActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_fileUploadCheckboxActionPerformed
+        this.firePropertyChange(OptionsPanelController.PROP_CHANGED, null, null);
+    }//GEN-LAST:event_fileUploadCheckboxActionPerformed
+
     @NbBundle.Messages({
         "# {0} - userName",
         "# {1} - email",
@@ -387,9 +440,11 @@ private void licenseInfoAddButtonActionPerformed(java.awt.event.ActionEvent evt)
         "# {0} - idNumber",
         "CTMalwareScannerOptionsPanel_licenseInfo_id=ID: {0}",
         "# {0} - maxDailyLookups",
-        "CTMalwareScannerOptionsPanel_malwareScans_maxDailyHashLookups=Max Hash lookups: {0}/day",
+        "# {1} - resetSuffix",
+        "CTMalwareScannerOptionsPanel_malwareScans_maxDailyHashLookups=Max Hash lookups: {0}{1}",
         "# {0} - maxDailyFileLookups",
-        "CTMalwareScannerOptionsPanel_malwareScans_maxDailyFileLookups=Max file uploads: {0}/day",
+        "# {1} - resetSuffix",
+        "CTMalwareScannerOptionsPanel_malwareScans_maxDailyFileLookups=Max file uploads: {0}{1}",
         "# {0} - countersResetDate",
         "CTMalwareScannerOptionsPanel_malwareScans_countersReset=Counters reset: {0}",
         "# {0} - hashLookupsRemaining",
@@ -425,7 +480,7 @@ private synchronized void renderLicenseState() {
         this.malwareScansMessageLabel.setVisible(StringUtils.isNotBlank(this.authTokenMessage));
         this.malwareScansMessageLabel.setText(this.authTokenMessage);
 
-        if (authTokenResponse == null) {
+        if (authTokenResponse == null || this.licenseInfo == null) {
             this.maxHashLookupsLabel.setVisible(false);
             this.maxFileUploadsLabel.setVisible(false);
             this.countersResetLabel.setVisible(false);
@@ -433,15 +488,62 @@ private synchronized void renderLicenseState() {
             this.fileUploadsRemainingLabel.setVisible(false);
         } else {
             this.maxHashLookupsLabel.setVisible(true);
-            this.maxHashLookupsLabel.setText(Bundle.CTMalwareScannerOptionsPanel_malwareScans_maxDailyHashLookups(this.authTokenResponse.getHashLookupLimit()));
+            this.maxHashLookupsLabel.setText(Bundle.CTMalwareScannerOptionsPanel_malwareScans_maxDailyHashLookups(
+                    this.authTokenResponse.getHashLookupLimit(),
+                    getResetSuffix(this.licenseInfo.getDecryptedLicense().getLimitType())));
+
             this.maxFileUploadsLabel.setVisible(true);
-            this.maxFileUploadsLabel.setText(Bundle.CTMalwareScannerOptionsPanel_malwareScans_maxDailyFileLookups(this.authTokenResponse.getFileUploadLimit()));
+            this.maxFileUploadsLabel.setText(Bundle.CTMalwareScannerOptionsPanel_malwareScans_maxDailyFileLookups(
+                    this.authTokenResponse.getFileUploadLimit(),
+                    getResetSuffix(this.licenseInfo.getDecryptedLicense().getLimitType())));
+
             this.countersResetLabel.setVisible(true);
-            this.countersResetLabel.setText(Bundle.CTMalwareScannerOptionsPanel_malwareScans_countersReset(this.authTokenResponse.getResetDate() == null ? "" : MALWARE_SCANS_RESET_FORMAT.format(this.authTokenResponse.getResetDate())));
+            this.countersResetLabel.setText(getCountersResetText(this.licenseInfo.getDecryptedLicense().getLimitType(), this.authTokenResponse));
+
             this.hashLookupsRemainingLabel.setVisible(true);
-            this.hashLookupsRemainingLabel.setText(Bundle.CTMalwareScannerOptionsPanel_malwareScans_hashLookupsRemaining(remaining(this.authTokenResponse.getHashLookupLimit(), this.authTokenResponse.getHashLookupCount())));
+            this.hashLookupsRemainingLabel.setText(
+                    Bundle.CTMalwareScannerOptionsPanel_malwareScans_hashLookupsRemaining(
+                            remaining(this.authTokenResponse.getHashLookupLimit(), this.authTokenResponse.getHashLookupCount())));
+
             this.fileUploadsRemainingLabel.setVisible(true);
-            this.fileUploadsRemainingLabel.setText(Bundle.CTMalwareScannerOptionsPanel_malwareScans_fileUploadsRemaining(remaining(this.authTokenResponse.getFileUploadLimit(), this.authTokenResponse.getFileUploadCount())));
+            this.fileUploadsRemainingLabel.setText(
+                    Bundle.CTMalwareScannerOptionsPanel_malwareScans_fileUploadsRemaining(
+                            remaining(this.authTokenResponse.getFileUploadLimit(), this.authTokenResponse.getFileUploadCount())));
+        }
+    }
+
+    private static String getCountersResetText(LicenseLimitType limitType, AuthTokenResponse authTokenResponse) {
+        if (limitType == null || limitType == LicenseLimitType.NO_RESET) {
+            return "";
+        } else {
+            return Bundle.CTMalwareScannerOptionsPanel_malwareScans_countersReset(
+                    MALWARE_SCANS_RESET_FORMAT.format(authTokenResponse.getResetDate()));
+        }
+    }
+
+    @Messages({
+        "CTMalwareScannerOptionsPanel_getResetSuffix_hourly=/hour",
+        "CTMalwareScannerOptionsPanel_getResetSuffix_daily=/day",
+        "CTMalwareScannerOptionsPanel_getResetSuffix_weekly=/week",
+        "CTMalwareScannerOptionsPanel_getResetSuffix_monthly=/month"
+    })
+    private String getResetSuffix(LicenseLimitType limitType) {
+        if (limitType == null) {
+            return "";
+        }
+
+        switch (limitType) {
+            case HOURLY:
+                return Bundle.CTMalwareScannerOptionsPanel_getResetSuffix_hourly();
+            case DAILY:
+                return Bundle.CTMalwareScannerOptionsPanel_getResetSuffix_daily();
+            case WEEKLY:
+                return Bundle.CTMalwareScannerOptionsPanel_getResetSuffix_weekly();
+            case MONTHLY:
+                return Bundle.CTMalwareScannerOptionsPanel_getResetSuffix_monthly();
+            case NO_RESET:
+            default:
+                return "";
         }
     }
 
@@ -501,7 +603,7 @@ protected void done() {
             try {
                 LicenseResponse licenseResponse = get();
                 SwingUtilities.invokeLater(() -> acceptEula(licenseResponse));
-            } catch (InterruptedException ex) {
+            } catch (InterruptedException | CancellationException ex) {
                 // ignore cancellation; just load current license
                 setLicenseDisplay(licenseInfo, null);
                 loadMalwareScansInfo(licenseInfo);
@@ -557,7 +659,7 @@ protected void done() {
             AuthTokenResponse authTokenResponse = null;
             try {
                 authTokenResponse = get();
-            } catch (InterruptedException ex) {
+            } catch (InterruptedException | CancellationException ex) {
                 // ignore cancellation
             } catch (ExecutionException ex) {
                 if (ex.getCause() != null && ex.getCause() instanceof CTCloudException cloudEx) {
@@ -589,6 +691,7 @@ protected void done() {
 
     // Variables declaration - do not modify//GEN-BEGIN:variables
     private javax.swing.JLabel countersResetLabel;
+    private javax.swing.JCheckBox fileUploadCheckbox;
     private javax.swing.JLabel fileUploadsRemainingLabel;
     private javax.swing.JLabel hashLookupsRemainingLabel;
     private javax.swing.JButton licenseInfoAddButton;
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/EULADialog.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/EULADialog.java
index 31056e1f2a982412340a782755ffe77e98c5fdce..0279cafb9b70e5a4b25c9b0df972334e239ddf93 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/EULADialog.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/EULADialog.java
@@ -36,7 +36,7 @@
 /**
  * Dialog for displaying the Cyber Triage EULA before the license is saved.
  */
-public class EULADialog extends javax.swing.JDialog {
+class EULADialog extends javax.swing.JDialog {
 
     private static final Logger LOGGER = Logger.getLogger(EULADialog.class.getName());
     private static final String EULA_RESOURCE = "EULA.htm";
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/MalwareIngestSettings.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/MalwareIngestSettings.java
new file mode 100644
index 0000000000000000000000000000000000000000..11e671d56106a92b37696b0caefc1f89770092d8
--- /dev/null
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/MalwareIngestSettings.java
@@ -0,0 +1,37 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2023 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.basistech.df.cybertriage.autopsy.ctoptions.ctcloud;
+
+/**
+ * Settings for the malware ingest module.
+ */
+public class MalwareIngestSettings {
+
+    private boolean uploadFiles = true;
+
+    public boolean isUploadFiles() {
+        return uploadFiles;
+    }
+
+    public MalwareIngestSettings setUploadFiles(boolean uploadFiles) {
+        this.uploadFiles = uploadFiles;
+        return this;
+    }
+
+}
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/BatchProcessor.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/BatchProcessor.java
index eab025a6416569645480ca93437cc353a22ed467..a65d319dc219b5bc5410bde84fe96846618c3dcd 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/BatchProcessor.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/BatchProcessor.java
@@ -21,14 +21,10 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
 import java.util.function.Consumer;
 
 /**
@@ -36,7 +32,7 @@
  * blocks (and subsequently add and flush operations) until previous batch
  * finishes.
  */
-public class BatchProcessor<T> {
+class BatchProcessor<T> {
 
     private ExecutorService processingExecutorService = Executors.newSingleThreadExecutor();
 
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/Bundle.properties-MERGED b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/Bundle.properties-MERGED
index 0c97a98c2eeb0a162daa3d5feca53a591f77df7d..4380a9607c8ce4600aabba5cd89473c2aca49e73 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/Bundle.properties-MERGED
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/Bundle.properties-MERGED
@@ -1,3 +1,7 @@
+MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_desc=Waiting for all uploaded files to complete scanning.
+MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_title=Waiting for File Upload Results
+MalwareScanIngestModule_longPollForNotFound_timeout_desc=There was a timeout while waiting for file uploads to be processed.  Please try again later.
+MalwareScanIngestModule_longPollForNotFound_timeout_title=File Upload Results Timeout
 MalwareScanIngestModule_malwareTypeDisplayName=Malware
 # {0} - errorResponse
 MalwareScanIngestModule_SharedProcessing_authTokenResponseError_desc=Received error: ''{0}'' when fetching the API authentication token for the license
@@ -6,6 +10,8 @@ MalwareScanIngestModule_SharedProcessing_createAnalysisResult_No=NO
 MalwareScanIngestModule_SharedProcessing_createAnalysisResult_Yes=YES
 MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc=The remaining hash lookups for this license have been exhausted
 MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_title=Hash Lookups Exhausted
+MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_desc=Not all files were processed because hash lookup limits were exceeded. Please try again when your limits reset.
+MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_title=Lookup Limits Exceeded
 MalwareScanIngestModule_SharedProcessing_flushTimeout_desc=A timeout occurred while finishing processing
 MalwareScanIngestModule_SharedProcessing_flushTimeout_title=Processing Timeout
 MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc=An error occurred while processing hash lookup results
@@ -16,12 +22,22 @@ MalwareScanIngestModule_SharedProcessing_repServicenResponseError_title=Lookup A
 MalwareScanIngestModule_ShareProcessing_batchTimeout_desc=Batch processing timed out
 MalwareScanIngestModule_ShareProcessing_batchTimeout_title=Batch Processing Timeout
 # {0} - remainingLookups
-MalwareScanIngestModule_ShareProcessing_lowLimitWarning_desc=This license only has {0} lookups remaining
-MalwareScanIngestModule_ShareProcessing_lowLimitWarning_title=Hash Lookups Low
+MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_desc=This license only has {0} lookups remaining.
+MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_title=Hash Lookups Low
+# {0} - remainingUploads
+MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_desc=This license only has {0} file uploads remaining.
+MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_title=File Uploads Limit Low
 MalwareScanIngestModule_ShareProcessing_noLicense_desc=No Cyber Triage license could be loaded.  Cyber Triage processing will be disabled.
 MalwareScanIngestModule_ShareProcessing_noLicense_title=No Cyber Triage License
-MalwareScanIngestModule_ShareProcessing_noRemaining_desc=There are no more remaining hash lookups for this license at this time.  Cyber Triage processing will be disabled.
-MalwareScanIngestModule_ShareProcessing_noRemaining_title=No remaining lookups
+MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc=There are no more remaining hash lookups for this license at this time.  Malware scanning will be disabled.
+MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_title=No remaining lookups
+MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_desc=There are no more remaining file uploads for this license at this time.  File uploading will be disabled.
+MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_title=No remaining file uploads
+MalwareScanIngestModule_uploadFile_noRemainingFileUploads_desc=There are no more file uploads on this license at this time.  File uploads will be disabled for remaining uploads.
+MalwareScanIngestModule_uploadFile_noRemainingFileUploads_title=No Remaining File Uploads
+# {0} - objectId
+MalwareScanIngestModule_uploadFile_notUploadable_desc=A file did not meet requirements for upload (object id: {0}).
+MalwareScanIngestModule_uploadFile_notUploadable_title=Not Able to Upload
 MalwareScanIngestModuleFactory_description=The malware scan ingest module queries the Cyber Triage cloud API for any possible malicious executables.
 MalwareScanIngestModuleFactory_displayName=Cyber Triage Malware Scanner
 MalwareScanIngestModuleFactory_version=1.0.0
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java
index 6d3f7d4766d9a53a02b5b8e5589639ee43ed66c3..c52e72a350db8f8c6267460ec4531e6d19082912 100644
--- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java
@@ -19,15 +19,21 @@
 package com.basistech.df.cybertriage.autopsy.malwarescan;
 
 import com.basistech.df.cybertriage.autopsy.ctapi.CTApiDAO;
+import com.basistech.df.cybertriage.autopsy.ctapi.CTCloudException;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.AuthTokenResponse;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.AuthenticatedRequestData;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.CTCloudBean;
 import com.basistech.df.cybertriage.autopsy.ctapi.json.LicenseInfo;
+import com.basistech.df.cybertriage.autopsy.ctapi.json.MalwareResultBean.Status;
+import com.basistech.df.cybertriage.autopsy.ctapi.json.MetadataUploadRequest;
 import com.basistech.df.cybertriage.autopsy.ctoptions.ctcloud.CTLicensePersistence;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HexFormat;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -36,7 +42,9 @@
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.collections4.MapUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.curator.shaded.com.google.common.collect.Lists;
 import org.openide.util.NbBundle.Messages;
 import org.sleuthkit.autopsy.casemodule.Case;
 import org.sleuthkit.autopsy.coreutils.Logger;
@@ -49,6 +57,7 @@
 import org.sleuthkit.datamodel.AnalysisResult;
 import org.sleuthkit.datamodel.Blackboard;
 import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.ReadContentInputStream;
 import org.sleuthkit.datamodel.HashUtility;
 import org.sleuthkit.datamodel.HashUtility.HashResult;
 import org.sleuthkit.datamodel.HashUtility.HashType;
@@ -60,7 +69,7 @@
 /**
  * Uses CT cloud API to determine if file is malware
  */
-public class MalwareScanIngestModule implements FileIngestModule {
+class MalwareScanIngestModule implements FileIngestModule {
 
     private static final SharedProcessing sharedProcessing = new SharedProcessing();
 
@@ -93,6 +102,14 @@ private static class SharedProcessing {
         //minimum lookups left before issuing warning
         private static final long LOW_LOOKUPS_REMAINING = 250;
 
+        //minimum file uploads left before issuing warning
+        private static final long LOW_UPLOADS_REMAINING = 25;
+
+        private static final long MIN_UPLOAD_SIZE = 1;
+        private static final long MAX_UPLOAD_SIZE = 1_000_000_000;
+        private static final int NUM_FILE_UPLOAD_RETRIES = 7;
+        private static final long FILE_UPLOAD_RETRY_SLEEP_MILLIS = 60 * 1000;
+
         private static final Set<String> EXECUTABLE_MIME_TYPES = Stream.of(
                 "application/x-bat",//NON-NLS
                 "application/x-dosexec",//NON-NLS
@@ -111,129 +128,243 @@ private static class SharedProcessing {
         private static final String MALWARE_CONFIG = "Cyber Triage Cloud";
 
         private static final Logger logger = Logger.getLogger(MalwareScanIngestModule.class.getName());
-        private final BatchProcessor<FileRecord> batchProcessor = new BatchProcessor<FileRecord>(BATCH_SIZE, FLUSH_SECS_TIMEOUT, this::handleBatch);
+
+        private final BatchProcessor<FileRecord> batchProcessor = new BatchProcessor<FileRecord>(
+                BATCH_SIZE,
+                FLUSH_SECS_TIMEOUT,
+                (lst) -> SharedProcessing.this.handleBatch(SharedProcessing.this.ingestJobState, lst));
 
         private final CTLicensePersistence ctSettingsPersistence = CTLicensePersistence.getInstance();
         private final CTApiDAO ctApiDAO = CTApiDAO.getInstance();
 
-        private RunState runState = null;
-
-        private SleuthkitCase tskCase = null;
-        private FileTypeDetector fileTypeDetector = null;
-        private LicenseInfo licenseInfo = null;
-        private BlackboardArtifact.Type malwareType = null;
-        private long dsId = 0;
-        private long ingestJobId = 0;
+        private IngestJobState ingestJobState = null;
 
         @Messages({
-            "MalwareScanIngestModule_ShareProcessing_lowLimitWarning_title=Hash Lookups Low",
-            "# {0} - remainingLookups",
-            "MalwareScanIngestModule_ShareProcessing_lowLimitWarning_desc=This license only has {0} lookups remaining",
             "MalwareScanIngestModule_malwareTypeDisplayName=Malware",
             "MalwareScanIngestModule_ShareProcessing_noLicense_title=No Cyber Triage License",
             "MalwareScanIngestModule_ShareProcessing_noLicense_desc=No Cyber Triage license could be loaded.  Cyber Triage processing will be disabled.",
-            "MalwareScanIngestModule_ShareProcessing_noRemaining_title=No remaining lookups",
-            "MalwareScanIngestModule_ShareProcessing_noRemaining_desc=There are no more remaining hash lookups for this license at this time.  Cyber Triage processing will be disabled."
-        })
+            "MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_title=No remaining lookups",
+            "MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc=There are no more remaining hash lookups for this license at this time.  Malware scanning will be disabled.",
+            "MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_title=Hash Lookups Low",
+            "# {0} - remainingLookups",
+            "MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_desc=This license only has {0} lookups remaining.",
+            "MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_title=No remaining file uploads",
+            "MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_desc=There are no more remaining file uploads for this license at this time.  File uploading will be disabled.",
+            "MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_title=File Uploads Limit Low",
+            "# {0} - remainingUploads",
+            "MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_desc=This license only has {0} file uploads remaining.",})
         synchronized void startUp(IngestJobContext context) throws IngestModuleException {
             // only run this code once per startup
-            if (runState == RunState.STARTED_UP || runState == RunState.DISABLED) {
+            if (ingestJobState != null) {
                 return;
             }
 
             try {
-                // get saved license
-                Optional<LicenseInfo> licenseInfoOpt = ctSettingsPersistence.loadLicenseInfo();
-                if (licenseInfoOpt.isEmpty() || licenseInfoOpt.get().getDecryptedLicense() == null) {
-                    notifyWarning(
-                            Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_title(),
-                            Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_desc(),
-                            null);
-                    runState = RunState.DISABLED;
-                    return;
-                }
+                ingestJobState = getNewJobState(context);
+            } catch (Exception ex) {
+                ingestJobState = IngestJobState.DISABLED;
+                throw new IngestModuleException("An exception occurred on MalwareScanIngestModule startup", ex);
+            }
+        }
+
+        /**
+         * Sets up the state necessary for a new ingest job.
+         *
+         * @param context The ingest job context.
+         * @return A pair of the runtime state (i.e. started up, disabled) and
+         * parameters required for the job.
+         * @throws Exception
+         */
+        private IngestJobState getNewJobState(IngestJobContext context) throws Exception {
+            // get saved license
+            Optional<LicenseInfo> licenseInfoOpt = ctSettingsPersistence.loadLicenseInfo();
+            if (licenseInfoOpt.isEmpty() || licenseInfoOpt.get().getDecryptedLicense() == null) {
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_title(),
+                        Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_desc(),
+                        null);
+
+                return IngestJobState.DISABLED;
+            }
 
-                AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfoOpt.get().getDecryptedLicense());
-                // syncronously fetch malware scans info
+            AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfoOpt.get().getDecryptedLicense());
+            // syncronously fetch malware scans info
 
-                // determine lookups remaining
-                long lookupsRemaining = remaining(authTokenResponse.getHashLookupLimit(), authTokenResponse.getHashLookupCount());
-                if (lookupsRemaining <= 0) {
+            // determine lookups remaining
+            long lookupsRemaining = remaining(authTokenResponse.getHashLookupLimit(), authTokenResponse.getHashLookupCount());
+            if (lookupsRemaining <= 0) {
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_title(),
+                        Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc(),
+                        null);
+
+                return IngestJobState.DISABLED;
+            } else if (lookupsRemaining < LOW_LOOKUPS_REMAINING) {
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_title(),
+                        Bundle.MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_desc(lookupsRemaining),
+                        null);
+            }
+
+            // determine lookups remaining
+            boolean uploadFiles = ctSettingsPersistence.loadMalwareIngestSettings().isUploadFiles();
+            if (uploadFiles) {
+                long uploadsRemaining = remaining(authTokenResponse.getFileUploadLimit(), authTokenResponse.getFileUploadCount());
+                if (uploadsRemaining <= 0) {
                     notifyWarning(
-                            Bundle.MalwareScanIngestModule_ShareProcessing_noRemaining_title(),
-                            Bundle.MalwareScanIngestModule_ShareProcessing_noRemaining_desc(),
+                            Bundle.MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_title(),
+                            Bundle.MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_desc(),
                             null);
-                    runState = RunState.DISABLED;
-                    return;
-                } else if (lookupsRemaining < LOW_LOOKUPS_REMAINING) {
+                    uploadFiles = false;
+                } else if (lookupsRemaining < LOW_UPLOADS_REMAINING) {
                     notifyWarning(
-                            Bundle.MalwareScanIngestModule_ShareProcessing_lowLimitWarning_title(),
-                            Bundle.MalwareScanIngestModule_ShareProcessing_lowLimitWarning_desc(lookupsRemaining),
+                            Bundle.MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_title(),
+                            Bundle.MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_desc(lookupsRemaining),
                             null);
                 }
-
-                // setup necessary variables for processing
-                tskCase = Case.getCurrentCaseThrows().getSleuthkitCase();
-                malwareType = tskCase.getBlackboard().getOrAddArtifactType(
-                        MALWARE_TYPE_NAME,
-                        Bundle.MalwareScanIngestModule_malwareTypeDisplayName(),
-                        BlackboardArtifact.Category.ANALYSIS_RESULT);
-                fileTypeDetector = new FileTypeDetector();
-                dsId = context.getDataSource().getId();
-                ingestJobId = context.getJobId();
-                licenseInfo = licenseInfoOpt.get();
-
-                // set run state to initialized
-                runState = RunState.STARTED_UP;
-            } catch (Exception ex) {
-                runState = RunState.DISABLED;
-                throw new IngestModuleException("An exception occurred on MalwareScanIngestModule startup", ex);
             }
+
+            // setup necessary variables for processing
+            SleuthkitCase tskCase = Case.getCurrentCaseThrows().getSleuthkitCase();
+            BlackboardArtifact.Type malwareType = tskCase.getBlackboard().getOrAddArtifactType(
+                    MALWARE_TYPE_NAME,
+                    Bundle.MalwareScanIngestModule_malwareTypeDisplayName(),
+                    BlackboardArtifact.Category.ANALYSIS_RESULT);
+
+            return new IngestJobState(
+                    context,
+                    tskCase,
+                    new PathNormalizer(tskCase),
+                    new FileTypeDetector(),
+                    licenseInfoOpt.get(),
+                    malwareType,
+                    uploadFiles,
+                    true
+            );
         }
 
+        /**
+         * Determines remaining given a possibly null limit and used count.
+         *
+         * @param limit The limit (can be null).
+         * @param used The number used (can be null).
+         * @return The remaining amount.
+         */
         private static long remaining(Long limit, Long used) {
             limit = limit == null ? 0 : limit;
             used = used == null ? 0 : used;
             return limit - used;
         }
 
-        private String getOrCalcHash(AbstractFile af) {
-            if (StringUtils.isNotBlank(af.getMd5Hash())) {
-                return af.getMd5Hash();
+        /**
+         * Gets the md5 hash from the abstract file or calculates it.
+         *
+         * @param af The abstract file.
+         * @return The md5 hash (or null if could not be determined).
+         */
+        private static String getOrCalcHash(AbstractFile af, HashType hashType) {
+            switch (hashType) {
+                case MD5:
+                    if (StringUtils.isNotBlank(af.getMd5Hash())) {
+                        return af.getMd5Hash();
+                    }
+                    break;
+                case SHA256:
+                    if (StringUtils.isNotBlank(af.getSha256Hash())) {
+                        return af.getSha256Hash();
+                    }
             }
 
             try {
-                List<HashResult> hashResults = HashUtility.calculateHashes(af, Collections.singletonList(HashType.MD5));
+                List<HashResult> hashResults = HashUtility.calculateHashes(af, Collections.singletonList(hashType));
                 if (CollectionUtils.isNotEmpty(hashResults)) {
                     for (HashResult hashResult : hashResults) {
-                        if (hashResult.getType() == HashType.MD5) {
+                        if (hashResult.getType() == hashType) {
                             return hashResult.getValue();
                         }
                     }
                 }
             } catch (TskCoreException ex) {
                 logger.log(Level.WARNING,
-                        MessageFormat.format("An error occurred while processing file name: {0} and obj id: {1}.",
+                        MessageFormat.format("An error occurred while processing hash for file name: {0} and obj id: {1} and hash type {2}.",
                                 af.getName(),
-                                af.getId()),
+                                af.getId(),
+                                hashType.name()),
                         ex);
             }
 
             return null;
         }
 
+        /**
+         * Gets or calculates the md5 for a file.
+         *
+         * @param af The file.
+         * @return The hash.
+         */
+        private static String getOrCalcMd5(AbstractFile af) {
+            return getOrCalcHash(af, HashType.MD5);
+        }
+
+        /**
+         * Gets or calculates the sha256 for a file.
+         *
+         * @param af The file.
+         * @return The hash.
+         */
+        private static String getOrCalcSha256(AbstractFile af) {
+            return getOrCalcHash(af, HashType.SHA256);
+        }
+
+        /**
+         * Gets or calculates the sha1 for a file.
+         *
+         * @param af The file.
+         * @return The hash.
+         */
+        private static String getOrCalcSha1(AbstractFile af) throws NoSuchAlgorithmException, ReadContentInputStream.ReadContentInputStreamException {
+            if (StringUtils.isNotBlank(af.getSha1Hash())) {
+                return af.getSha1Hash();
+            }
+            // taken from https://stackoverflow.com/questions/6293713/java-how-to-create-sha-1-for-a-file
+            MessageDigest digest = MessageDigest.getInstance("SHA-1");
+            ReadContentInputStream afStream = new ReadContentInputStream(af);
+            int n = 0;
+            byte[] buffer = new byte[8192];
+            while (n != -1) {
+                n = afStream.read(buffer);
+                if (n > 0) {
+                    digest.update(buffer, 0, n);
+                }
+            }
+            byte[] hashBytes = digest.digest();
+            String hashString = HexFormat.of().formatHex(hashBytes);
+            return hashString;
+        }
+
+        /**
+         * Processes a file. The file goes through the lookup process if the
+         * file meets acceptable criteria: 1) not FileKnown.KNOWN 2) is
+         * executable 3) does not have any pre-existing TSK_MALWARE results 4)
+         * file lookup has not been disabled.
+         *
+         * @param af The file.
+         * @return OK or ERROR.
+         */
         @Messages({
             "MalwareScanIngestModule_ShareProcessing_batchTimeout_title=Batch Processing Timeout",
             "MalwareScanIngestModule_ShareProcessing_batchTimeout_desc=Batch processing timed out"
         })
         IngestModule.ProcessResult process(AbstractFile af) {
             try {
-                if (runState == RunState.STARTED_UP
+                if (ingestJobState != null
+                        && ingestJobState.isDoFileLookups()
+                        && !ingestJobState.getIngestJobContext().fileIngestIsCancelled()
                         && af.getKnown() != TskData.FileKnown.KNOWN
-                        && EXECUTABLE_MIME_TYPES.contains(StringUtils.defaultString(fileTypeDetector.getMIMEType(af)).trim().toLowerCase())
-                        && CollectionUtils.isEmpty(af.getAnalysisResults(malwareType))) {
+                        && EXECUTABLE_MIME_TYPES.contains(StringUtils.defaultString(ingestJobState.getFileTypeDetector().getMIMEType(af)).trim().toLowerCase())
+                        && CollectionUtils.isEmpty(af.getAnalysisResults(ingestJobState.getMalwareType()))) {
 
-                    String md5 = getOrCalcHash(af);
+                    String md5 = getOrCalcMd5(af);
                     if (StringUtils.isNotBlank(md5)) {
                         batchProcessor.add(new FileRecord(af.getId(), md5));
                     }
@@ -254,6 +385,13 @@ IngestModule.ProcessResult process(AbstractFile af) {
             }
         }
 
+        /**
+         * Handles a batch of files to be sent to CT file lookup for results.
+         *
+         * @param ingestJobState The current state of operation for the ingest
+         * job.
+         * @param fileRecords The file records to be uploaded.
+         */
         @Messages({
             "MalwareScanIngestModule_SharedProcessing_authTokenResponseError_title=Authentication API error",
             "# {0} - errorResponse",
@@ -265,8 +403,12 @@ IngestModule.ProcessResult process(AbstractFile af) {
             "MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc=The remaining hash lookups for this license have been exhausted",
             "MalwareScanIngestModule_SharedProcessing_generalProcessingError_title=Hash Lookup Error",
             "MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc=An error occurred while processing hash lookup results",})
-        private void handleBatch(List<FileRecord> fileRecords) {
-            if (runState != RunState.STARTED_UP || fileRecords == null || fileRecords.isEmpty()) {
+        private void handleBatch(IngestJobState ingestJobState, List<FileRecord> fileRecords) {
+            if (ingestJobState == null
+                    || !ingestJobState.isDoFileLookups()
+                    || ingestJobState.getIngestJobContext().fileIngestIsCancelled()
+                    || fileRecords == null
+                    || fileRecords.isEmpty()) {
                 return;
             }
 
@@ -278,11 +420,10 @@ private void handleBatch(List<FileRecord> fileRecords) {
                     continue;
                 }
 
-                String sanitizedMd5 = sanitizedMd5(fr.getMd5hash());
+                String sanitizedMd5 = normalizedMd5(fr.getMd5hash());
                 md5ToObjId
                         .computeIfAbsent(sanitizedMd5, (k) -> new ArrayList<>())
                         .add(fr.getObjId());
-
             }
 
             List<String> md5Hashes = new ArrayList<>(md5ToObjId.keySet());
@@ -292,78 +433,398 @@ private void handleBatch(List<FileRecord> fileRecords) {
             }
 
             try {
-                // get an auth token with the license
-                AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfo.getDecryptedLicense());
+                List<CTCloudBean> repResult = getHashLookupResults(ingestJobState, md5Hashes);
+                handleLookupResults(ingestJobState, md5ToObjId, repResult);
+            } catch (Exception ex) {
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(),
+                        Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc(),
+                        ex);
+            }
+        }
+
+        /**
+         * Handles results received from CT Cloud.
+         *
+         * @param ingestJobState The current state of operations of the ingest
+         * module.
+         * @param md5ToObjId The mapping of md5 to a list of object ids.
+         * @param repResult The ct cloud results.
+         * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException
+         * @throws TskCoreException
+         */
+        @Messages({
+            "MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_title=Lookup Limits Exceeded",
+            "MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_desc=Not all files were processed because hash lookup limits were exceeded. Please try again when your limits reset.",})
+        private void handleLookupResults(IngestJobState ingestJobState, Map<String, List<Long>> md5ToObjId, List<CTCloudBean> repResult) throws Blackboard.BlackboardException, TskCoreException, TskCoreException, CTCloudException, NoSuchAlgorithmException, ReadContentInputStream.ReadContentInputStreamException {
+            if (CollectionUtils.isEmpty(repResult)) {
+                return;
+            }
+
+            Map<Status, List<CTCloudBean>> statusGroupings = repResult.stream()
+                    .filter(bean -> bean.getMalwareResult() != null)
+                    .collect(Collectors.groupingBy(bean -> bean.getMalwareResult().getStatus()));
+
+            // for all found items, create analysis results
+            List<CTCloudBean> found = statusGroupings.get(Status.FOUND);
+            createAnalysisResults(ingestJobState, found, md5ToObjId);
+
+            // if being scanned, check list to run later
+            handleNonFoundResults(ingestJobState, md5ToObjId, statusGroupings.get(Status.BEING_SCANNED), false);
+
+            // if not found, try upload
+            handleNonFoundResults(ingestJobState, md5ToObjId, statusGroupings.get(Status.NOT_FOUND), true);
+
+            // indicate a general error if some result in an error
+            if (CollectionUtils.isNotEmpty(statusGroupings.get(Status.ERROR))) {
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(),
+                        Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc(),
+                        null);
+            }
+
+            // indicate some results were not processed if limits exceeded in results
+            if (CollectionUtils.isNotEmpty(statusGroupings.get(Status.LIMITS_EXCEEDED))) {
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_title(),
+                        Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_desc(),
+                        null);
+            }
+        }
+
+        /**
+         * Handles a CT cloud response objects that have a status that isn't
+         * FOUND but still are queryable (i.e. NOT_FOUND, BEING_SCANNED).
+         *
+         * @param ingestJobState The current state of operations of the ingest
+         * module.
+         * @param md5ToObjId The mapping of md5 to a list of object ids.
+         * @param results The ct cloud results.
+         * @param performFileUpload True if the class of results warrants file
+         * upload (i.e. NOT_FOUND)
+         */
+        private void handleNonFoundResults(IngestJobState ingestJobState, Map<String, List<Long>> md5ToObjId, List<CTCloudBean> results, boolean performFileUpload) throws CTCloudException, TskCoreException, NoSuchAlgorithmException, ReadContentInputStream.ReadContentInputStreamException {
+            if (CollectionUtils.isNotEmpty(results)
+                    && ingestJobState.isDoFileLookups()
+                    && ((performFileUpload && ingestJobState.isUploadUnknownFiles()) || (!performFileUpload && ingestJobState.isQueryForMissing()))) {
+
+                for (CTCloudBean beingScanned : CollectionUtils.emptyIfNull(results)) {
+
+                    String sanitizedMd5 = normalizedMd5(beingScanned.getMd5HashValue());
+                    if (StringUtils.isBlank(sanitizedMd5)) {
+                        continue;
+                    }
+                    List<Long> correspondingObjIds = md5ToObjId.get(sanitizedMd5);
+                    if (CollectionUtils.isEmpty(correspondingObjIds)) {
+                        continue;
+                    }
+
+                    if (performFileUpload) {
+                        uploadFile(ingestJobState, sanitizedMd5, correspondingObjIds.get(0));
+                    }
 
-                // make sure we are in bounds for the remaining scans
-                long remainingScans = remaining(authTokenResponse.getHashLookupLimit(), authTokenResponse.getHashLookupCount());
-                if (remainingScans <= 0) {
-                    runState = RunState.DISABLED;
+                    ingestJobState.getUnidentifiedHashes().put(sanitizedMd5, correspondingObjIds);
+                }
+            }
+        }
+
+        /**
+         * Makes CT Cloud REST API query for results regarding the status of a
+         * list of md5 hashes for executables.
+         *
+         * @param ingestJobState The current state of operations of the ingest
+         * module.
+         * @param md5Hashes The md5 hashes to check.
+         * @return The results from CT Cloud.
+         * @throws CTCloudException
+         */
+        private List<CTCloudBean> getHashLookupResults(IngestJobState ingestJobState, List<String> md5Hashes) throws CTCloudException {
+            if (ingestJobState.getIngestJobContext().fileIngestIsCancelled()) {
+                return Collections.emptyList();
+            }
+
+            // get an auth token with the license
+            AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(ingestJobState.getLicenseInfo().getDecryptedLicense());
+
+            // make sure we are in bounds for the remaining scans
+            long remainingScans = remaining(authTokenResponse.getHashLookupLimit(), authTokenResponse.getHashLookupCount());
+            if (remainingScans <= 0) {
+                ingestJobState.disableDoFileLookups();
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_title(),
+                        Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc(),
+                        null);
+                return Collections.emptyList();
+            } else if (ingestJobState.getIngestJobContext().fileIngestIsCancelled()) {
+                return Collections.emptyList();
+            }
+
+            // while we have a valid auth token, also check file uploads.
+            if (ingestJobState.isUploadUnknownFiles()) {
+                long remainingUploads = remaining(authTokenResponse.getFileUploadLimit(), authTokenResponse.getFileUploadCount());
+                if (remainingUploads <= 0) {
+                    ingestJobState.disableUploadUnknownFiles();
                     notifyWarning(
-                            Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_title(),
-                            Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc(),
+                            Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_title(),
+                            Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_desc(),
                             null);
-                    return;
                 }
+            }
 
-                // using auth token, get results
-                List<CTCloudBean> repResult = ctApiDAO.getReputationResults(
-                        new AuthenticatedRequestData(licenseInfo.getDecryptedLicense(), authTokenResponse),
-                        md5Hashes
-                );
+            // using auth token, get results
+            return ctApiDAO.getReputationResults(
+                    new AuthenticatedRequestData(ingestJobState.getLicenseInfo().getDecryptedLicense(), authTokenResponse),
+                    md5Hashes
+            );
+        }
 
-                List<BlackboardArtifact> createdArtifacts = new ArrayList<>();
-                if (!CollectionUtils.isEmpty(repResult)) {
-                    SleuthkitCase.CaseDbTransaction trans = null;
-                    try {
-                        trans = tskCase.beginTransaction();
-                        for (CTCloudBean result : repResult) {
-                            String sanitizedMd5 = sanitizedMd5(result.getMd5HashValue());
-                            List<Long> objIds = md5ToObjId.remove(sanitizedMd5);
-                            if (objIds == null || objIds.isEmpty()) {
-                                continue;
-                            }
-
-                            for (Long objId : objIds) {
-                                AnalysisResult res = createAnalysisResult(objId, result, trans);
-                                if (res != null) {
-                                    createdArtifacts.add(res);
-                                }
-                            }
-                        }
+        /**
+         * Normalizes an md5 string for the purposes of lookup in a map.
+         *
+         * @param orig The original value.
+         * @return The normalized value
+         */
+        private static String normalizedMd5(String orig) {
+            return StringUtils.defaultString(orig).trim().toLowerCase();
+        }
 
-                        trans.commit();
-                        trans = null;
-                    } finally {
-                        if (trans != null) {
-                            trans.rollback();
-                            createdArtifacts.clear();
-                            trans = null;
-                        }
+        /**
+         * Whether or not an abstract file meets the requirements to be
+         * uploaded.
+         *
+         * @param af The abstract file.
+         * @return True if can be uploaded.
+         */
+        private static boolean isUploadable(AbstractFile af) {
+            long size = af.getSize();
+            return size >= MIN_UPLOAD_SIZE && size <= MAX_UPLOAD_SIZE;
+        }
+
+        /**
+         * Uploads a file to CT Cloud if the file is valid for upload.
+         *
+         * @param ingestJobState The current state of the ingest job.
+         * @param objId The object id of the file to upload to CT cloud.
+         * @return True if successfully uploaded.
+         * @throws CTCloudException
+         * @throws TskCoreException
+         */
+        @Messages({
+            "MalwareScanIngestModule_uploadFile_notUploadable_title=Not Able to Upload",
+            "# {0} - objectId",
+            "MalwareScanIngestModule_uploadFile_notUploadable_desc=A file did not meet requirements for upload (object id: {0}).",
+            "MalwareScanIngestModule_uploadFile_noRemainingFileUploads_title=No Remaining File Uploads",
+            "MalwareScanIngestModule_uploadFile_noRemainingFileUploads_desc=There are no more file uploads on this license at this time.  File uploads will be disabled for remaining uploads.",})
+        private boolean uploadFile(IngestJobState ingestJobState, String md5, long objId) throws CTCloudException, TskCoreException, NoSuchAlgorithmException, ReadContentInputStream.ReadContentInputStreamException {
+            if (!ingestJobState.isUploadUnknownFiles() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) {
+                return false;
+            }
+
+            AbstractFile af = ingestJobState.getTskCase().getAbstractFileById(objId);
+            if (af == null) {
+                return false;
+            }
+
+            if (!isUploadable(af)) {
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_uploadFile_notUploadable_title(),
+                        Bundle.MalwareScanIngestModule_uploadFile_notUploadable_desc(objId),
+                        null);
+                return false;
+            }
+
+            // get auth token / file upload url
+            AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(ingestJobState.getLicenseInfo().getDecryptedLicense(), true);
+            if (StringUtils.isBlank(authTokenResponse.getFileUploadUrl())) {
+                throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR);
+            } else if (remaining(authTokenResponse.getFileUploadLimit(), authTokenResponse.getFileUploadCount()) <= 0) {
+                // don't proceed with upload if reached limit
+                ingestJobState.disableUploadUnknownFiles();
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_title(),
+                        Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_desc(),
+                        null);
+
+                return false;
+            } else if (ingestJobState.getIngestJobContext().fileIngestIsCancelled()) {
+                return false;
+            }
+
+            // upload bytes
+            ReadContentInputStream fileInputStream = new ReadContentInputStream(af);
+            ctApiDAO.uploadFile(authTokenResponse.getFileUploadUrl(), af.getName(), fileInputStream);
+
+            // upload metadata
+            MetadataUploadRequest metaRequest = new MetadataUploadRequest()
+                    .setCreatedDate(af.getCrtime() == 0 ? null : af.getCrtime())
+                    .setFilePath(ingestJobState.getPathNormalizer().normalizePath(af.getUniquePath()))
+                    .setFileSizeBytes(af.getSize())
+                    .setFileUploadUrl(authTokenResponse.getFileUploadUrl())
+                    .setMd5(md5)
+                    .setSha1(getOrCalcSha1(af))
+                    .setSha256(getOrCalcSha256(af));
+
+            ctApiDAO.uploadMeta(new AuthenticatedRequestData(ingestJobState.getLicenseInfo().getDecryptedLicense(), authTokenResponse), metaRequest);
+            return true;
+        }
+
+        /**
+         * Does long polling for any pending results.
+         *
+         * @param ingestJobState The state of the ingest job.
+         * @throws InterruptedException
+         * @throws CTCloudException
+         * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException
+         * @throws TskCoreException
+         */
+        @Messages({
+            "MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_title=Waiting for File Upload Results",
+            "MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_desc=Waiting for all uploaded files to complete scanning.",
+            "MalwareScanIngestModule_longPollForNotFound_timeout_title=File Upload Results Timeout",
+            "MalwareScanIngestModule_longPollForNotFound_timeout_desc=There was a timeout while waiting for file uploads to be processed.  Please try again later.",})
+        private void longPollForNotFound(IngestJobState ingestJobState) throws InterruptedException, CTCloudException, Blackboard.BlackboardException, TskCoreException {
+            if (!ingestJobState.isDoFileLookups()
+                    || !ingestJobState.isQueryForMissing()
+                    || MapUtils.isEmpty(ingestJobState.getUnidentifiedHashes())
+                    || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) {
+                return;
+            }
+
+            MessageNotifyUtil.Notify.info(
+                    Bundle.MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_title(),
+                    Bundle.MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_desc()
+            );
+
+            Map<String, List<Long>> remaining = new HashMap<>(ingestJobState.getUnidentifiedHashes());
+
+            for (int retry = 0; retry < NUM_FILE_UPLOAD_RETRIES; retry++) {
+                List<List<String>> md5Batches = Lists.partition(new ArrayList<>(remaining.keySet()), BATCH_SIZE);
+                for (List<String> batch : md5Batches) {
+                    // if we have exceeded limits or cancelled, then we're done.
+                    if (!ingestJobState.isDoFileLookups() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) {
+                        return;
                     }
 
-                    if (!CollectionUtils.isEmpty(createdArtifacts)) {
-                        tskCase.getBlackboard().postArtifacts(createdArtifacts, Bundle.MalwareScanIngestModuleFactory_displayName(), ingestJobId);
+                    List<CTCloudBean> repResult = getHashLookupResults(ingestJobState, batch);
+
+                    Map<Status, List<CTCloudBean>> statusGroupings = repResult.stream()
+                            .filter(bean -> bean.getMalwareResult() != null)
+                            .collect(Collectors.groupingBy(bean -> bean.getMalwareResult().getStatus()));
+
+                    // for all found items, create analysis results
+                    List<CTCloudBean> found = statusGroupings.get(Status.FOUND);
+
+                    createAnalysisResults(ingestJobState, found, remaining);
+
+                    // remove any found items from the list of items to long poll for
+                    for (CTCloudBean foundItem : CollectionUtils.emptyIfNull(found)) {
+                        String normalizedMd5 = normalizedMd5(foundItem.getMd5HashValue());
+                        remaining.remove(normalizedMd5);
                     }
                 }
-            } catch (Exception ex) {
-                notifyWarning(
-                        Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(),
-                        Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc(),
-                        ex);
+
+                if (remaining.isEmpty()) {
+                    return;
+                }
+
+                // exponential backoff before trying again
+                long waitMultiplier = ((long) Math.pow(2, retry));
+                for (int i = 0; i < waitMultiplier; i++) {
+                    if (!ingestJobState.isDoFileLookups() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) {
+                        return;
+                    }
+
+                    Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS);
+                }
             }
+
+            notifyWarning(
+                    Bundle.MalwareScanIngestModule_longPollForNotFound_timeout_title(),
+                    Bundle.MalwareScanIngestModule_longPollForNotFound_timeout_desc(),
+                    null
+            );
         }
 
-        private String sanitizedMd5(String orig) {
-            return StringUtils.defaultString(orig).trim().toLowerCase();
+        /**
+         * Creates TSK_MALWARE analysis results based on a list of cloud beans
+         * received from the CT cloud api.
+         *
+         * @param ingestJobState The ingest job state.
+         * @param repResult The list of cloud beans. Only cloud beans with a
+         * malware status
+         * @param md5ToObjId The mapping of md5
+         * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException
+         * @throws TskCoreException
+         */
+        private void createAnalysisResults(IngestJobState ingestJobState, List<CTCloudBean> repResult, Map<String, List<Long>> md5ToObjId) throws Blackboard.BlackboardException, TskCoreException {
+            if (CollectionUtils.isEmpty(repResult)) {
+                return;
+            }
+
+            List<BlackboardArtifact> createdArtifacts = new ArrayList<>();
+            SleuthkitCase.CaseDbTransaction trans = null;
+            try {
+                trans = ingestJobState.getTskCase().beginTransaction();
+                for (CTCloudBean result : repResult) {
+                    String sanitizedMd5 = normalizedMd5(result.getMd5HashValue());
+                    List<Long> objIds = md5ToObjId.remove(sanitizedMd5);
+                    if (CollectionUtils.isEmpty(objIds)) {
+                        continue;
+                    }
+
+                    for (Long objId : objIds) {
+                        AnalysisResult res = createAnalysisResult(ingestJobState, trans, result, objId);
+                        if (res != null) {
+                            createdArtifacts.add(res);
+                        }
+                    }
+                }
+
+                trans.commit();
+                trans = null;
+            } finally {
+                if (trans != null) {
+                    trans.rollback();
+                    createdArtifacts.clear();
+                    trans = null;
+                }
+            }
+
+            if (!CollectionUtils.isEmpty(createdArtifacts)) {
+                ingestJobState.getTskCase().getBlackboard().postArtifacts(
+                        createdArtifacts,
+                        Bundle.MalwareScanIngestModuleFactory_displayName(),
+                        ingestJobState.getIngestJobId()
+                );
+            }
+
         }
 
+        /**
+         * Creates an analysis result for the given information.
+         *
+         * @param ingestJobState The state of the ingest job.
+         * @param trans The case database transaction to use.
+         * @param cloudBean The bean indicating the malware result.
+         * @param objId The object id of the corresponding file that will
+         * receive the analysis result.
+         * @return The created analysis result or null if none created.
+         * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException
+         */
         @Messages({
             "MalwareScanIngestModule_SharedProcessing_createAnalysisResult_Yes=YES",
             "MalwareScanIngestModule_SharedProcessing_createAnalysisResult_No=NO"
         })
-        private AnalysisResult createAnalysisResult(Long objId, CTCloudBean cloudBean, SleuthkitCase.CaseDbTransaction trans) throws Blackboard.BlackboardException {
-            if (objId == null || cloudBean == null || cloudBean.getMalwareResult() == null) {
+        private AnalysisResult createAnalysisResult(IngestJobState ingestJobState, SleuthkitCase.CaseDbTransaction trans, CTCloudBean cloudBean, Long objId) throws Blackboard.BlackboardException {
+            if (objId == null || cloudBean == null || cloudBean.getMalwareResult() == null || cloudBean.getMalwareResult().getStatus() != Status.FOUND) {
+                logger.log(Level.WARNING, MessageFormat.format("Attempting to create analysis result with invalid parameters [objId: {0}, cloud bean status: {1}]",
+                        objId == null
+                                ? "<null>"
+                                : objId,
+                        (cloudBean == null || cloudBean.getMalwareResult() == null || cloudBean.getMalwareResult().getStatus() == null)
+                        ? "<null>"
+                        : cloudBean.getMalwareResult().getStatus().name()
+                ));
                 return null;
             }
 
@@ -377,10 +838,10 @@ private AnalysisResult createAnalysisResult(Long objId, CTCloudBean cloudBean, S
 
             String justification = cloudBean.getMalwareResult().getStatusDescription();
 
-            return tskCase.getBlackboard().newAnalysisResult(
-                    malwareType,
+            return ingestJobState.getTskCase().getBlackboard().newAnalysisResult(
+                    ingestJobState.getMalwareType(),
                     objId,
-                    dsId,
+                    ingestJobState.getDsId(),
                     score,
                     conclusion,
                     MALWARE_CONFIG,
@@ -389,39 +850,52 @@ private AnalysisResult createAnalysisResult(Long objId, CTCloudBean cloudBean, S
                     trans).getAnalysisResult();
         }
 
+        /**
+         * Called when ingest should shut down.
+         */
         @Messages({
             "MalwareScanIngestModule_SharedProcessing_flushTimeout_title=Processing Timeout",
             "MalwareScanIngestModule_SharedProcessing_flushTimeout_desc=A timeout occurred while finishing processing"
         })
         synchronized void shutDown() {
             // if already shut down, return
-            if (runState == RunState.SHUT_DOWN) {
+            if (ingestJobState == null) {
                 return;
             }
 
             // flush any remaining items
             try {
                 batchProcessor.flushAndReset();
+                longPollForNotFound(ingestJobState);
             } catch (InterruptedException ex) {
                 notifyWarning(
                         Bundle.MalwareScanIngestModule_SharedProcessing_flushTimeout_title(),
                         Bundle.MalwareScanIngestModule_SharedProcessing_flushTimeout_desc(),
                         ex);
+            } catch (Exception ex) {
+                notifyWarning(
+                        Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(),
+                        Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc(),
+                        ex);
             } finally {
                 // set state to shut down and clear any remaining
-                runState = RunState.SHUT_DOWN;
+                ingestJobState = null;
             }
         }
 
-        private void notifyWarning(String title, String message, Exception ex) {
+        /**
+         * Creates a warning notification to display in the lower right corner
+         * and a corresponding log message.
+         *
+         * @param title The title of the warning.
+         * @param message The message of the warning.
+         * @param ex The corresponding exception (or null if none).
+         */
+        private static void notifyWarning(String title, String message, Exception ex) {
             MessageNotifyUtil.Notify.warn(title, message);
             logger.log(Level.WARNING, message, ex);
         }
 
-        private enum RunState {
-            STARTED_UP, DISABLED, SHUT_DOWN
-        }
-
         class FileRecord {
 
             private final long objId;
@@ -441,5 +915,116 @@ String getMd5hash() {
             }
 
         }
+
+        /**
+         * Represents the state of the current ingest job.
+         *
+         * NOTE: if doFileLookups is false, most variables will likely be null
+         * (TSK case, file type detector, etc.) and should not be used. The
+         * contract for this class should be that if doFileLookups is true or
+         * uploadUnknownFiles is true, the remaining variables should be non
+         * null, if doFileLookups is false and uploadUnknownFiles is false, no
+         * other access to this class can be made reliably.
+         */
+        static class IngestJobState {
+
+            static final IngestJobState DISABLED = new IngestJobState(
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    false,
+                    false
+            );
+
+            private final SleuthkitCase tskCase;
+            private final FileTypeDetector fileTypeDetector;
+            private final LicenseInfo licenseInfo;
+            private final BlackboardArtifact.Type malwareType;
+            private final long dsId;
+            private final long ingestJobId;
+            private final boolean queryForMissing;
+            private final Map<String, List<Long>> unidentifiedHashes = new HashMap<>();
+
+            // this can change mid run
+            private boolean uploadUnknownFiles;
+            private boolean doFileLookups;
+            private final IngestJobContext ingestJobContext;
+            private final PathNormalizer pathNormalizer;
+
+            IngestJobState(IngestJobContext ingestJobContext, SleuthkitCase tskCase, PathNormalizer pathNormalizer, FileTypeDetector fileTypeDetector, LicenseInfo licenseInfo, BlackboardArtifact.Type malwareType, boolean uploadUnknownFiles, boolean doFileLookups) {
+                this.tskCase = tskCase;
+                this.fileTypeDetector = fileTypeDetector;
+                this.pathNormalizer = pathNormalizer;
+                this.licenseInfo = licenseInfo;
+                this.malwareType = malwareType;
+                this.dsId = ingestJobContext == null ? 0L : ingestJobContext.getDataSource().getId();
+                this.ingestJobId = ingestJobContext == null ? 0L : ingestJobContext.getJobId();
+                this.ingestJobContext = ingestJobContext;
+                // for now, querying for any missing files will be tied to whether initially we should upload files and do lookups at all
+                this.queryForMissing = uploadUnknownFiles && doFileLookups;
+                this.uploadUnknownFiles = uploadUnknownFiles;
+                this.doFileLookups = doFileLookups;
+            }
+
+            SleuthkitCase getTskCase() {
+                return tskCase;
+            }
+
+            IngestJobContext getIngestJobContext() {
+                return ingestJobContext;
+            }
+
+            FileTypeDetector getFileTypeDetector() {
+                return fileTypeDetector;
+            }
+
+            LicenseInfo getLicenseInfo() {
+                return licenseInfo;
+            }
+
+            BlackboardArtifact.Type getMalwareType() {
+                return malwareType;
+            }
+
+            long getDsId() {
+                return dsId;
+            }
+
+            long getIngestJobId() {
+                return ingestJobId;
+            }
+
+            Map<String, List<Long>> getUnidentifiedHashes() {
+                return unidentifiedHashes;
+            }
+
+            boolean isQueryForMissing() {
+                return queryForMissing;
+            }
+
+            boolean isUploadUnknownFiles() {
+                return uploadUnknownFiles;
+            }
+
+            void disableUploadUnknownFiles() {
+                this.uploadUnknownFiles = false;
+            }
+
+            boolean isDoFileLookups() {
+                return doFileLookups;
+            }
+
+            void disableDoFileLookups() {
+                this.doFileLookups = false;
+            }
+
+            public PathNormalizer getPathNormalizer() {
+                return pathNormalizer;
+            }
+
+        }
     }
 }
diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/PathNormalizer.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/PathNormalizer.java
new file mode 100644
index 0000000000000000000000000000000000000000..f8a0a299a5771fcb3d3c90d1bb0ce597cdd3e3b7
--- /dev/null
+++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/PathNormalizer.java
@@ -0,0 +1,201 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2023 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.basistech.df.cybertriage.autopsy.malwarescan;
+
+import com.google.common.net.InetAddresses;
+import java.net.InetAddress;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.datamodel.SleuthkitCase;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Utility class to anonymize paths.
+ */
+class PathNormalizer {
+
+    private static final Logger LOGGER = Logger.getLogger(PathNormalizer.class.getName());
+
+    private static final String ANONYMIZED_USERNAME = "<user>";
+    private static final String ANONYMIZED_IP = "<private_ip>";
+    private static final String ANONYMIZED_HOSTNAME = "<hostname>";
+    private static final String FORWARD_SLASH = "/";
+    private static final String BACK_SLASH = "\\";
+
+    private static final Pattern USER_PATH_FORWARD_SLASH_REGEX = Pattern.compile("(?<!all )([/]{0,1}\\Qusers\\E/)(?!(public|Default|defaultAccount|All Users))([^/]+)(/){0,1}", Pattern.CASE_INSENSITIVE);
+    private static final Pattern USER_PATH_BACK_SLASH_REGEX = Pattern.compile("(?<!all )([\\\\]{0,1}\\Qusers\\E\\\\)(?!(public|Default|defaultAccount|All Users))([^\\\\]+)([\\\\]){0,1}", Pattern.CASE_INSENSITIVE);
+
+    private static final Pattern USER_PATH_FORWARD_SLASH_REGEX_XP = Pattern.compile("([/]{0,1}\\Qdocuments and settings\\E/)(?!(Default User|All Users))([^/]+)(/){0,1}", Pattern.CASE_INSENSITIVE);
+    private static final Pattern USER_PATH_BACK_SLASH_REGEX_XP = Pattern.compile("([\\\\]{0,1}\\Qdocuments and settings\\E\\\\)(?!(Default User|All Users))([^\\\\]+)(\\\\){0,1}", Pattern.CASE_INSENSITIVE);
+
+    private static final Pattern UNC_PATH_FORWARD_SLASH_PATTERN = Pattern.compile("(//)([^/]+)(/){0,1}");
+    private static final Pattern UNC_PATH_BACK_SLASH_PATTERN = Pattern.compile("(\\\\\\\\)([^\\\\]+)(\\\\){0,1}");
+
+    private static final String USERNAME_REGEX_REPLACEMENT = "$1" + ANONYMIZED_USERNAME + "$4";
+
+    private final SleuthkitCase skCase;
+
+    PathNormalizer(SleuthkitCase skCase) {
+        this.skCase = skCase;
+    }
+
+    protected List<String> getUsernames() {
+        try {
+            return this.skCase.getOsAccountManager().getOsAccounts().stream()
+                    .filter(acct -> acct != null)
+                    .map(acct -> acct.getLoginName().orElse(null))
+                    .filter(StringUtils::isNotBlank)
+                    .collect(Collectors.toList());
+        } catch (TskCoreException ex) {
+            LOGGER.log(Level.WARNING, "There was an error getting current os accounts", ex);
+            return Collections.emptyList();
+        }
+    }
+
+    public String normalizePath(String inputString) {
+        if (StringUtils.isBlank(inputString)) {
+            return "";
+        }
+
+        String anonymousString = anonymizeUserFromPathsWithForwardSlashes(inputString);
+        anonymousString = anonymizeUserFromPathsWithBackSlashes(anonymousString);
+        anonymousString = anonymizeServerFromUNCPath(anonymousString);
+
+        return anonymousString;
+    }
+
+    private String anonymizeUserFromPathsWithForwardSlashes(String stringWithUsername) {
+        String anonymousString = stringWithUsername;
+        anonymousString = regexReplace(anonymousString, USER_PATH_FORWARD_SLASH_REGEX_XP, USERNAME_REGEX_REPLACEMENT);
+        anonymousString = regexReplace(anonymousString, USER_PATH_FORWARD_SLASH_REGEX, USERNAME_REGEX_REPLACEMENT);
+        anonymousString = replaceFolder(anonymousString, getUsernames(), ANONYMIZED_USERNAME, FORWARD_SLASH);
+        return anonymousString;
+    }
+
+    // Most paths in CyberTriage are normalized with forward slashes
+    // but there can still be strings containing paths that are not normalized such paths contained in arguments or event log payloads
+    private String anonymizeUserFromPathsWithBackSlashes(String stringWithUsername) {
+        String anonymousString = stringWithUsername;
+        anonymousString = regexReplace(anonymousString, USER_PATH_BACK_SLASH_REGEX_XP, USERNAME_REGEX_REPLACEMENT);
+        anonymousString = regexReplace(anonymousString, USER_PATH_BACK_SLASH_REGEX, USERNAME_REGEX_REPLACEMENT);
+        anonymousString = replaceFolder(anonymousString, getUsernames(), ANONYMIZED_USERNAME, BACK_SLASH);
+
+        return anonymousString;
+    }
+
+    private String anonymizeServerFromUNCPath(String inputString) {
+
+        Set<String> serverNames = new HashSet<>();
+        String anonymousString = inputString.toLowerCase(Locale.ENGLISH);
+
+        Matcher forwardSlashMatcher = UNC_PATH_FORWARD_SLASH_PATTERN.matcher(anonymousString);
+        while (forwardSlashMatcher.find()) {
+            String serverName = forwardSlashMatcher.group(2);
+            serverNames.add(serverName);
+        }
+
+        Matcher backSlashMatcher = UNC_PATH_BACK_SLASH_PATTERN.matcher(anonymousString);
+        while (backSlashMatcher.find()) {
+            String serverName = backSlashMatcher.group(2);
+            serverNames.add(serverName);
+        }
+
+        for (String serverName : serverNames) {
+
+            if (StringUtils.isBlank(serverName)) {
+                continue;
+            }
+
+            if (InetAddresses.isInetAddress(serverName) && isLocalIP(serverName)) {
+                anonymousString = replaceFolder(anonymousString, Collections.singletonList(serverName), ANONYMIZED_IP);
+            } else {
+                anonymousString = replaceFolder(anonymousString, Collections.singletonList(serverName), ANONYMIZED_HOSTNAME);
+            }
+
+        }
+
+        return anonymousString;
+    }
+
+    private static String regexReplace(String orig, Pattern pattern, String regexReplacement) {
+        Matcher matcher = pattern.matcher(orig);
+        return matcher.replaceAll(regexReplacement);
+    }
+
+    private static String replaceFolder(String orig, List<String> valuesToReplace, String replacementValue) {
+        String anonymized = orig;
+        anonymized = replaceFolder(anonymized, valuesToReplace, replacementValue, FORWARD_SLASH);
+        anonymized = replaceFolder(anonymized, valuesToReplace, replacementValue, BACK_SLASH);
+        return anonymized;
+    }
+
+    private static String replaceFolder(String orig, List<String> valuesToReplace, String replacementValue, String folderDelimiter) {
+        if (orig == null || valuesToReplace == null) {
+            return orig;
+        }
+
+        String anonymousString = orig;
+
+        // ensure non-null
+        folderDelimiter = StringUtils.defaultString(folderDelimiter);
+        replacementValue = StringUtils.defaultString(replacementValue);
+
+        // replace
+        for (String valueToReplace : valuesToReplace) {
+            if (StringUtils.isNotEmpty(valueToReplace)) {
+                anonymousString = StringUtils.replace(anonymousString,
+                        folderDelimiter + valueToReplace + folderDelimiter,
+                        folderDelimiter + replacementValue + folderDelimiter);
+            }
+        }
+
+        return anonymousString;
+    }
+
+    /**
+     * Returns true if IP Address is Any Local / Site Local / Link Local / Loop
+     * back local. Sample list "0.0.0.0", wildcard addres
+     * "10.1.1.1","10.10.10.10", site local address "127.0.0.0","127.2.2.2",
+     * loopback address "169.254.0.0","169.254.10.10", Link local address
+     * "172.16.0.0","172.31.245.245", site local address
+     *
+     * @param ipAddress
+     * @return
+     */
+    public static boolean isLocalIP(String ipAddress) {
+        try {
+            InetAddress a = InetAddresses.forString(ipAddress);
+            return a.isAnyLocalAddress() || a.isSiteLocalAddress()
+                    || a.isLoopbackAddress() || a.isLinkLocalAddress();
+        } catch (IllegalArgumentException ex) {
+            LOGGER.log(Level.WARNING, "Invalid IP string", ex);
+            return false;
+        }
+    }
+
+}
diff --git a/docs/doxygen-user/images/ct_malware_license_agreement.png b/docs/doxygen-user/images/ct_malware_license_agreement.png
new file mode 100644
index 0000000000000000000000000000000000000000..030a722e469c6ba1964a40fb9b5c3efcc99d11ce
Binary files /dev/null and b/docs/doxygen-user/images/ct_malware_license_agreement.png differ
diff --git a/docs/doxygen-user/images/ct_malware_scanner_options_panel.png b/docs/doxygen-user/images/ct_malware_scanner_options_panel.png
new file mode 100644
index 0000000000000000000000000000000000000000..1f45693d138a12a838aba40ab18a31f65c5f6d82
Binary files /dev/null and b/docs/doxygen-user/images/ct_malware_scanner_options_panel.png differ
diff --git a/docs/doxygen-user/images/ct_upload_file.png b/docs/doxygen-user/images/ct_upload_file.png
new file mode 100644
index 0000000000000000000000000000000000000000..38c595330d609bcf6ade1072a90a3e63f2eedc52
Binary files /dev/null and b/docs/doxygen-user/images/ct_upload_file.png differ