From 8809569567646343bded7eb8aadb6c4e71d2c0f6 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 24 Jul 2023 09:34:15 -0400 Subject: [PATCH 01/32] file upload --- .../autopsy/ctapi/CTCloudHttpClient.java | 60 +++++++--- .../cybertriage/autopsy/ctapi/CtApiDAO.java | 18 ++- .../ctapi/json/MetadataUploadRequest.java | 109 ++++++++++++++++++ .../autopsy/malwarescan/FileUpload.java | 78 +++++++++++++ 4 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/MetadataUploadRequest.java create mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java 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 d774ff1661..0fa9feda54 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java @@ -21,17 +21,13 @@ package com.basistech.df.cybertriage.autopsy.ctapi; import com.basistech.df.cybertriage.autopsy.ctapi.util.ObjectMapperUtil; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.io.InputStream; import java.net.Authenticator; import java.net.InetAddress; import java.net.PasswordAuthentication; import java.net.URI; import java.net.URISyntaxException; import java.net.UnknownHostException; -import java.security.KeyManagementException; -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; @@ -41,13 +37,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.logging.Level; -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.lang3.StringUtils; import org.apache.http.HttpEntity; @@ -64,7 +54,9 @@ import org.apache.http.client.methods.HttpRequestBase; 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; @@ -175,10 +167,14 @@ public class CTCloudHttpClient { 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, ""); @@ -198,6 +194,40 @@ public class CTCloudHttpClient { return null; } + + public void doFileUploadPost(String urlPath, String fileName, InputStream fileIs) throws CTCloudException { + + try (CloseableHttpClient httpclient = createConnection(getProxySettings(), sslContext)) { + HttpPost post = new HttpPost(urlPath); + 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 (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 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 899af5545b..927cc10cbe 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.DecryptedLicenseResponse; 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; @@ -44,6 +46,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(); @@ -72,15 +76,27 @@ public class CTApiDAO { } 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(AUTH_TOKEN_REQUEST_PATH, getAuthParams(authenticatedRequestData), metaRequest, null); + } + private static Map getAuthParams(AuthenticatedRequestData authenticatedRequestData) { return new HashMap() { { 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 0000000000..57d59c3faa --- /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 sleuthkit 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/malwarescan/FileUpload.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java new file mode 100644 index 0000000000..64fb36f2a1 --- /dev/null +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java @@ -0,0 +1,78 @@ +/* + * Autopsy Forensic Browser + * + * Copyright 2023 Basis Technology Corp. + * Contact: carrier sleuthkit 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.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.DecryptedLicenseResponse; +import com.basistech.df.cybertriage.autopsy.ctapi.json.MetadataUploadRequest; +import org.apache.commons.lang3.StringUtils; +import org.sleuthkit.datamodel.AbstractFile; +import org.sleuthkit.datamodel.ReadContentInputStream; +import org.sleuthkit.datamodel.SleuthkitCase; +import org.sleuthkit.datamodel.TskCoreException; + +/** + * Handles uploading of files that are unknown. + */ +public class FileUpload { + + private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); + + private boolean isUnknown(CTCloudBean cloudBean) { + + } + + private boolean isUploadable(AbstractFile af) { + + } + + public boolean tryUpload(SleuthkitCase skCase, CTCloudBean cloudBean, long objId) { + + } + + private boolean upload(DecryptedLicenseResponse decrypted, AbstractFile af) throws CTCloudException, TskCoreException { + // get auth token / file upload url + AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(decrypted, true); + if (StringUtils.isBlank(authTokenResponse.getFileUploadUrl())) { + throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR); + } + + // upload bytes + ReadContentInputStream fileInputStream = new ReadContentInputStream(af); + ctApiDAO.uploadFile(authTokenResponse.getFileUploadUrl(), af.getName(), fileInputStream); + + // upload metadata + MetadataUploadRequest metaRequest = new MetadataUploadRequest() + .setCreatedDate(af.getCrtime()) + .setFilePath(af.getUniquePath()) + .setFileSizeBytes(af.getSize()) + .setFileUploadUrl(authTokenResponse.getFileUploadUrl()) + .setMd5(af.getMd5Hash()) + .setSha1(af.getSha1Hash()) + .setSha256(af.getSha256Hash()); + + ctApiDAO.uploadMeta(new AuthenticatedRequestData(decrypted, authTokenResponse), metaRequest); + return true; + } + +} From 418fdb2be3585f94394b550c39b72e0b7a932a0f Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 24 Jul 2023 11:08:40 -0400 Subject: [PATCH 02/32] updates --- .../autopsy/malwarescan/FileUpload.java | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java index 64fb36f2a1..73d99ee114 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java @@ -24,6 +24,7 @@ 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.DecryptedLicenseResponse; +import com.basistech.df.cybertriage.autopsy.ctapi.json.MalwareResultBean.Status; import com.basistech.df.cybertriage.autopsy.ctapi.json.MetadataUploadRequest; import org.apache.commons.lang3.StringUtils; import org.sleuthkit.datamodel.AbstractFile; @@ -35,19 +36,37 @@ import org.sleuthkit.datamodel.TskCoreException; * Handles uploading of files that are unknown. */ public class FileUpload { + private static final long MIN_SIZE = 1; + private static final long MAX_SIZE = 1_000_000_000; private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); private boolean isUnknown(CTCloudBean cloudBean) { - + return cloudBean != null + && cloudBean.getMalwareResult() != null + && cloudBean.getMalwareResult().getStatus() == Status.NOT_FOUND; } private boolean isUploadable(AbstractFile af) { - + long size = af.getSize(); + return size >= MIN_SIZE && size <= MAX_SIZE; } - public boolean tryUpload(SleuthkitCase skCase, CTCloudBean cloudBean, long objId) { - + public boolean tryUpload(SleuthkitCase skCase, CTCloudBean cloudBean, long objId) throws TskCoreException { + if (!isUnknown(cloudBean)) { + return false; + } + + AbstractFile af = skCase.getAbstractFileById(objId); + if (af == null) { + return false; + } + + if (!isUploadable(af)) { + return false; + } + + } private boolean upload(DecryptedLicenseResponse decrypted, AbstractFile af) throws CTCloudException, TskCoreException { From 08a649b01c13854f10e7f0232678daef8d1bed4f Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 24 Jul 2023 11:41:19 -0400 Subject: [PATCH 03/32] file upload updates --- .../ctoptions/ctcloud/Bundle.properties | 2 + .../ctcloud/CTMalwareScannerOptionsPanel.form | 49 +++++++++++++++ .../ctcloud/CTMalwareScannerOptionsPanel.java | 38 ++++++++++++ .../autopsy/malwarescan/FileUpload.java | 12 ++-- .../malwarescan/MalwareScanIngestModule.java | 62 ++++++++++++++++++- 5 files changed, 153 insertions(+), 10 deletions(-) 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 bd06716288..979010bb23 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 executable if executable is unknown +CTMalwareScannerOptionsPanel.fileUploadPanel.border.title=File Upload 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 77361419b6..8f5020d9b9 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 @@ -195,5 +195,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 d68168c3b6..94ba8fb948 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 @@ -214,6 +214,8 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { countersResetLabel = new javax.swing.JLabel(); hashLookupsRemainingLabel = new javax.swing.JLabel(); fileUploadsRemainingLabel = new javax.swing.JLabel(); + javax.swing.JPanel fileUploadPanel = new javax.swing.JPanel(); + fileUploadCheckbox = new javax.swing.JCheckBox(); setLayout(new java.awt.GridBagLayout()); @@ -346,6 +348,37 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST; gridBagConstraints.weightx = 1.0; add(malwareScansPanel, gridBagConstraints); + + 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, 32767)); + fileUploadCheckbox.setMinimumSize(new java.awt.Dimension(255, 50)); + fileUploadCheckbox.setPreferredSize(new java.awt.Dimension(255, 50)); + 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); }// //GEN-END:initComponents @Messages({ @@ -377,6 +410,10 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { } }//GEN-LAST:event_licenseInfoAddButtonActionPerformed + private void fileUploadCheckboxActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_fileUploadCheckboxActionPerformed + // TODO add your handling code here: + }//GEN-LAST:event_fileUploadCheckboxActionPerformed + @NbBundle.Messages({ "# {0} - userName", "# {1} - email", @@ -588,6 +625,7 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { // 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/malwarescan/FileUpload.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java index 73d99ee114..4803793514 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java @@ -36,8 +36,8 @@ import org.sleuthkit.datamodel.TskCoreException; * Handles uploading of files that are unknown. */ public class FileUpload { - private static final long MIN_SIZE = 1; - private static final long MAX_SIZE = 1_000_000_000; + private static final long MIN_UPLOAD_SIZE = 1; + private static final long MAX_UPLOAD_SIZE = 1_000_000_000; private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); @@ -49,10 +49,10 @@ public class FileUpload { private boolean isUploadable(AbstractFile af) { long size = af.getSize(); - return size >= MIN_SIZE && size <= MAX_SIZE; + return size >= MIN_UPLOAD_SIZE && size <= MAX_UPLOAD_SIZE; } - public boolean tryUpload(SleuthkitCase skCase, CTCloudBean cloudBean, long objId) throws TskCoreException { + private boolean upload(SleuthkitCase skCase, DecryptedLicenseResponse decrypted, CTCloudBean cloudBean, long objId) throws CTCloudException, TskCoreException { if (!isUnknown(cloudBean)) { return false; } @@ -66,10 +66,6 @@ public class FileUpload { return false; } - - } - - private boolean upload(DecryptedLicenseResponse decrypted, AbstractFile af) throws CTCloudException, TskCoreException { // get auth token / file upload url AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(decrypted, true); if (StringUtils.isBlank(authTokenResponse.getFileUploadUrl())) { 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 beffd8e594..dd8e45a085 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -19,10 +19,14 @@ 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.DecryptedLicenseResponse; import com.basistech.df.cybertriage.autopsy.ctapi.json.LicenseInfo; +import com.basistech.df.cybertriage.autopsy.ctapi.json.MalwareResultBean; +import com.basistech.df.cybertriage.autopsy.ctapi.json.MetadataUploadRequest; import com.basistech.df.cybertriage.autopsy.ctoptions.ctcloud.CTLicensePersistence; import java.util.ArrayList; import java.util.Collections; @@ -48,8 +52,10 @@ import org.sleuthkit.datamodel.AbstractFile; import org.sleuthkit.datamodel.AnalysisResult; import org.sleuthkit.datamodel.Blackboard; import org.sleuthkit.datamodel.BlackboardArtifact; +import org.sleuthkit.datamodel.ReadContentInputStream; import org.sleuthkit.datamodel.Score; import org.sleuthkit.datamodel.SleuthkitCase; +import org.sleuthkit.datamodel.TskCoreException; import org.sleuthkit.datamodel.TskData; /** @@ -88,6 +94,9 @@ public class MalwareScanIngestModule implements FileIngestModule { //minimum lookups left before issuing warning private static final long LOW_LOOKUPS_REMAINING = 250; + private static final long MIN_UPLOAD_SIZE = 1; + private static final long MAX_UPLOAD_SIZE = 1_000_000_000; + private static final Set EXECUTABLE_MIME_TYPES = Stream.of( "application/x-bat",//NON-NLS "application/x-dosexec",//NON-NLS @@ -112,7 +121,7 @@ public class MalwareScanIngestModule implements FileIngestModule { private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); private RunState runState = null; - + private SleuthkitCase tskCase = null; private FileTypeDetector fileTypeDetector = null; private LicenseInfo licenseInfo = null; @@ -192,6 +201,55 @@ public class MalwareScanIngestModule implements FileIngestModule { return limit - used; } + private boolean isUnknown(CTCloudBean cloudBean) { + return cloudBean != null + && cloudBean.getMalwareResult() != null + && cloudBean.getMalwareResult().getStatus() == MalwareResultBean.Status.NOT_FOUND; + } + + private boolean isUploadable(AbstractFile af) { + long size = af.getSize(); + return size >= MIN_UPLOAD_SIZE && size <= MAX_UPLOAD_SIZE; + } + + private boolean uploadFile(SleuthkitCase skCase, DecryptedLicenseResponse decrypted, CTCloudBean cloudBean, long objId) throws CTCloudException, TskCoreException { + if (!isUnknown(cloudBean)) { + return false; + } + + AbstractFile af = skCase.getAbstractFileById(objId); + if (af == null) { + return false; + } + + if (!isUploadable(af)) { + return false; + } + + // get auth token / file upload url + AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(decrypted, true); + if (StringUtils.isBlank(authTokenResponse.getFileUploadUrl())) { + throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR); + } + + // upload bytes + ReadContentInputStream fileInputStream = new ReadContentInputStream(af); + ctApiDAO.uploadFile(authTokenResponse.getFileUploadUrl(), af.getName(), fileInputStream); + + // upload metadata + MetadataUploadRequest metaRequest = new MetadataUploadRequest() + .setCreatedDate(af.getCrtime()) + .setFilePath(af.getUniquePath()) + .setFileSizeBytes(af.getSize()) + .setFileUploadUrl(authTokenResponse.getFileUploadUrl()) + .setMd5(af.getMd5Hash()) + .setSha1(af.getSha1Hash()) + .setSha256(af.getSha256Hash()); + + ctApiDAO.uploadMeta(new AuthenticatedRequestData(decrypted, authTokenResponse), metaRequest); + return true; + } + @Messages({ "MalwareScanIngestModule_ShareProcessing_batchTimeout_title=Batch Processing Timeout", "MalwareScanIngestModule_ShareProcessing_batchTimeout_desc=Batch processing timed out" @@ -310,7 +368,7 @@ public class MalwareScanIngestModule implements FileIngestModule { if (!CollectionUtils.isEmpty(createdArtifacts)) { tskCase.getBlackboard().postArtifacts(createdArtifacts, Bundle.MalwareScanIngestModuleFactory_displayName(), ingestJobId); } - + // if we only processed part of the batch, after processing, notify that we are out of scans. if (exceededScanLimit) { runState = RunState.DISABLED; From c94a1fe867ac1b957239edf0713d3ed7fb26509e Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 24 Jul 2023 11:51:41 -0400 Subject: [PATCH 04/32] updates --- .../ctcloud/CTLicensePersistence.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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 784e15234b..65e9f25773 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 @@ -40,6 +40,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()); @@ -95,7 +96,44 @@ public class CTLicensePersistence { }); } + public synchronized boolean saveMalwareSettings(MalwareIngestSettings malwareIngestSettings) { + if (malwareIngestSettings != null) { + File settingsFile = getMalwareIngestFile(); + try { + settingsFile.getParentFile().mkdirs(); + if (licenseResponse != null) { + objectMapper.writeValue(licenseFile, licenseResponse); + } else if (licenseFile.exists()) { + Files.delete(licenseFile.toPath()); + } + return true; + } catch (IOException ex) { + logger.log(Level.WARNING, "There was an error writing CyberTriage license to file: " + licenseFile.getAbsolutePath(), ex); + } + } + + return false; + } + + public synchronized MalwareIngestSettings loadMalwareIngestSettings() { + Optional toRet = Optional.empty(); + File licenseFile = getCTLicenseFile(); + if (licenseFile.exists() && licenseFile.isFile()) { + try { + toRet = Optional.ofNullable(objectMapper.readValue(licenseFile, LicenseResponse.class)); + } catch (IOException ex) { + logger.log(Level.WARNING, "There was an error reading CyberTriage license to file: " + licenseFile.getAbsolutePath(), ex); + } + } + + return toRet; + } + 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(); + } } From 2484f75d3c09ef12842dc1e57a3e6a57fa1fe2a1 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 24 Jul 2023 11:52:04 -0400 Subject: [PATCH 05/32] updates --- .../ctcloud/MalwareIngestSettings.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/MalwareIngestSettings.java 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 0000000000..11e671d561 --- /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 sleuthkit 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; + } + +} From fe26a6e00ff938d74ef6b7dbabc4f276f577a36c Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 24 Jul 2023 13:31:10 -0400 Subject: [PATCH 06/32] updates --- .../ctcloud/CTLicensePersistence.java | 28 ++-- .../ctcloud/CTMalwareScannerOptionsPanel.java | 18 ++- .../malwarescan/MalwareScanIngestModule.java | 131 +++++++++++------- 3 files changed, 113 insertions(+), 64 deletions(-) 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 67f1920110..e95d9e7279 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 @@ -92,19 +92,15 @@ public class CTLicensePersistence { }); } - public synchronized boolean saveMalwareSettings(MalwareIngestSettings malwareIngestSettings) { + public synchronized boolean saveMalwareSettings(MalwareIngestSettings malwareIngestSettings) { if (malwareIngestSettings != null) { File settingsFile = getMalwareIngestFile(); try { settingsFile.getParentFile().mkdirs(); - if (licenseResponse != null) { - objectMapper.writeValue(licenseFile, licenseResponse); - } else if (licenseFile.exists()) { - Files.delete(licenseFile.toPath()); - } + objectMapper.writeValue(settingsFile, malwareIngestSettings); return true; } catch (IOException ex) { - logger.log(Level.WARNING, "There was an error writing CyberTriage license to file: " + licenseFile.getAbsolutePath(), ex); + logger.log(Level.WARNING, "There was an error writing malware ingest settings to file: " + settingsFile.getAbsolutePath(), ex); } } @@ -112,19 +108,23 @@ public class CTLicensePersistence { } public synchronized MalwareIngestSettings loadMalwareIngestSettings() { - Optional toRet = Optional.empty(); - File licenseFile = getCTLicenseFile(); - if (licenseFile.exists() && licenseFile.isFile()) { + MalwareIngestSettings settings = null; + File settingsFile = getMalwareIngestFile(); + if (settingsFile.exists() && settingsFile.isFile()) { try { - toRet = Optional.ofNullable(objectMapper.readValue(licenseFile, LicenseResponse.class)); + settings = objectMapper.readValue(settingsFile, MalwareIngestSettings.class); } catch (IOException ex) { - logger.log(Level.WARNING, "There was an error reading CyberTriage license to file: " + licenseFile.getAbsolutePath(), 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 toRet; + return settings; } - + private File getCTLicenseFile() { return Paths.get(PlatformUtil.getModuleConfigDirectory(), CT_SETTINGS_DIR, CT_LICENSE_FILENAME).toFile(); } 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 94ba8fb948..b380dded42 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 @@ -111,6 +111,7 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { @Override public synchronized void saveSettings() { ctPersistence.saveLicenseResponse(getLicenseInfo()); + ctPersistence.saveMalwareSettings(getIngestSettings()); } @Override @@ -127,11 +128,26 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { 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; @@ -411,7 +427,7 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { }//GEN-LAST:event_licenseInfoAddButtonActionPerformed private void fileUploadCheckboxActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_fileUploadCheckboxActionPerformed - // TODO add your handling code here: + this.firePropertyChange(OptionsPanelController.PROP_CHANGED, null, null); }//GEN-LAST:event_fileUploadCheckboxActionPerformed @NbBundle.Messages({ 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 dd8e45a085..a509310391 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -40,6 +40,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.commons.collections4.CollectionUtils; 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; @@ -96,6 +97,9 @@ public class MalwareScanIngestModule implements FileIngestModule { 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 = 60 * 5; + private static final long FILE_UPLOAD_RETRY_SLEEP_MILLIS = 60 * 1000; + private static final Set EXECUTABLE_MIME_TYPES = Stream.of( "application/x-bat",//NON-NLS @@ -128,6 +132,7 @@ public class MalwareScanIngestModule implements FileIngestModule { private BlackboardArtifact.Type malwareType = null; private long dsId = 0; private long ingestJobId = 0; + private boolean uploadUnknownFiles = false; @Messages({ "MalwareScanIngestModule_ShareProcessing_lowLimitWarning_title=Hash Lookups Low", @@ -186,6 +191,7 @@ public class MalwareScanIngestModule implements FileIngestModule { dsId = context.getDataSource().getId(); ingestJobId = context.getJobId(); licenseInfo = licenseInfoOpt.get(); + uploadUnknownFiles = ctSettingsPersistence.loadMalwareIngestSettings().isUploadFiles(); // set run state to initialized runState = RunState.STARTED_UP; @@ -201,55 +207,6 @@ public class MalwareScanIngestModule implements FileIngestModule { return limit - used; } - private boolean isUnknown(CTCloudBean cloudBean) { - return cloudBean != null - && cloudBean.getMalwareResult() != null - && cloudBean.getMalwareResult().getStatus() == MalwareResultBean.Status.NOT_FOUND; - } - - private boolean isUploadable(AbstractFile af) { - long size = af.getSize(); - return size >= MIN_UPLOAD_SIZE && size <= MAX_UPLOAD_SIZE; - } - - private boolean uploadFile(SleuthkitCase skCase, DecryptedLicenseResponse decrypted, CTCloudBean cloudBean, long objId) throws CTCloudException, TskCoreException { - if (!isUnknown(cloudBean)) { - return false; - } - - AbstractFile af = skCase.getAbstractFileById(objId); - if (af == null) { - return false; - } - - if (!isUploadable(af)) { - return false; - } - - // get auth token / file upload url - AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(decrypted, true); - if (StringUtils.isBlank(authTokenResponse.getFileUploadUrl())) { - throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR); - } - - // upload bytes - ReadContentInputStream fileInputStream = new ReadContentInputStream(af); - ctApiDAO.uploadFile(authTokenResponse.getFileUploadUrl(), af.getName(), fileInputStream); - - // upload metadata - MetadataUploadRequest metaRequest = new MetadataUploadRequest() - .setCreatedDate(af.getCrtime()) - .setFilePath(af.getUniquePath()) - .setFileSizeBytes(af.getSize()) - .setFileUploadUrl(authTokenResponse.getFileUploadUrl()) - .setMd5(af.getMd5Hash()) - .setSha1(af.getSha1Hash()) - .setSha256(af.getSha256Hash()); - - ctApiDAO.uploadMeta(new AuthenticatedRequestData(decrypted, authTokenResponse), metaRequest); - return true; - } - @Messages({ "MalwareScanIngestModule_ShareProcessing_batchTimeout_title=Batch Processing Timeout", "MalwareScanIngestModule_ShareProcessing_batchTimeout_desc=Batch processing timed out" @@ -391,6 +348,82 @@ public class MalwareScanIngestModule implements FileIngestModule { return StringUtils.defaultString(orig).trim().toLowerCase(); } + private boolean isUnknown(CTCloudBean cloudBean) { + return cloudBean != null + && cloudBean.getMalwareResult() != null + && cloudBean.getMalwareResult().getStatus() == MalwareResultBean.Status.NOT_FOUND; + } + + private boolean isUploadable(AbstractFile af) { + long size = af.getSize(); + return size >= MIN_UPLOAD_SIZE && size <= MAX_UPLOAD_SIZE; + } + + private boolean uploadFile(CTCloudBean cloudBean, long objId) throws CTCloudException, TskCoreException { + if (!uploadUnknownFiles) { + return false; + } + + if (!isUnknown(cloudBean)) { + return false; + } + + AbstractFile af = skCase.getAbstractFileById(objId); + if (af == null) { + return false; + } + + if (!isUploadable(af)) { + return false; + } + + // get auth token / file upload url + AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(decrypted, 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 + uploadUnknownFiles = false; + 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()) + .setFilePath(af.getUniquePath()) + .setFileSizeBytes(af.getSize()) + .setFileUploadUrl(authTokenResponse.getFileUploadUrl()) + .setMd5(af.getMd5Hash()) + .setSha1(af.getSha1Hash()) + .setSha256(af.getSha256Hash()); + + ctApiDAO.uploadMeta(new AuthenticatedRequestData(decrypted, authTokenResponse), metaRequest); + return true; + } + + private boolean getUploadedFileResults(Map> md5objIdMapping) { + Map> remaining = new HashMap<>(md5objIdMapping); + + for (int retry = 0; retry < NUM_FILE_UPLOAD_RETRIES; retry++) { + List> md5Batches = Lists.partition(new ArrayList<>(remaining.keySet()), BATCH_SIZE); + for (List batch : md5Batches) { + // TODO query and capture still unknown + } + + if (remaining.isEmpty()) { + return true; + } + + + Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS); + } + + } + @Messages({ "MalwareScanIngestModule_SharedProcessing_createAnalysisResult_Yes=YES", "MalwareScanIngestModule_SharedProcessing_createAnalysisResult_No=NO" From 032993c858f8f88a32788d8756493d59d0f4170c Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Tue, 25 Jul 2023 19:07:32 -0400 Subject: [PATCH 07/32] updates --- .../malwarescan/MalwareScanIngestModule.java | 146 ++++++++++-------- 1 file changed, 82 insertions(+), 64 deletions(-) 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 e249b487d5..2a67b46792 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -26,6 +26,7 @@ import com.basistech.df.cybertriage.autopsy.ctapi.json.CTCloudBean; 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.MalwareResultBean; +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.text.MessageFormat; @@ -103,7 +104,6 @@ public class MalwareScanIngestModule implements FileIngestModule { private static final long MAX_UPLOAD_SIZE = 1_000_000_000; private static final int NUM_FILE_UPLOAD_RETRIES = 60 * 5; private static final long FILE_UPLOAD_RETRY_SLEEP_MILLIS = 60 * 1000; - private static final Set EXECUTABLE_MIME_TYPES = Stream.of( "application/x-bat",//NON-NLS @@ -128,6 +128,7 @@ public class MalwareScanIngestModule implements FileIngestModule { private final CTLicensePersistence ctSettingsPersistence = CTLicensePersistence.getInstance(); private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); + // TODO minimize state private RunState runState = null; private SleuthkitCase tskCase = null; @@ -137,6 +138,7 @@ public class MalwareScanIngestModule implements FileIngestModule { private long dsId = 0; private long ingestJobId = 0; private boolean uploadUnknownFiles = false; + private Map> unidentifiedHashes = null; @Messages({ "MalwareScanIngestModule_ShareProcessing_lowLimitWarning_title=Hash Lookups Low", @@ -196,7 +198,8 @@ public class MalwareScanIngestModule implements FileIngestModule { ingestJobId = context.getJobId(); licenseInfo = licenseInfoOpt.get(); uploadUnknownFiles = ctSettingsPersistence.loadMalwareIngestSettings().isUploadFiles(); - + unidentifiedHashes = new HashMap<>(); + // set run state to initialized runState = RunState.STARTED_UP; } catch (Exception ex) { @@ -306,60 +309,14 @@ public class MalwareScanIngestModule implements FileIngestModule { } try { - // get an auth token with the license - AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfo.getDecryptedLicense()); - - // make sure we are in bounds for the remaining scans - long remainingScans = remaining(authTokenResponse.getHashLookupLimit(), authTokenResponse.getHashLookupCount()); - if (remainingScans <= 0) { - runState = RunState.DISABLED; - notifyWarning( - Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_title(), - Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc(), - null); - return; - } - - // using auth token, get results - List repResult = ctApiDAO.getReputationResults( - new AuthenticatedRequestData(licenseInfo.getDecryptedLicense(), authTokenResponse), - md5Hashes - ); - - List 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 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); - } - } - } - - trans.commit(); - trans = null; - } finally { - if (trans != null) { - trans.rollback(); - createdArtifacts.clear(); - trans = null; - } - } - - if (!CollectionUtils.isEmpty(createdArtifacts)) { - tskCase.getBlackboard().postArtifacts(createdArtifacts, Bundle.MalwareScanIngestModuleFactory_displayName(), ingestJobId); - } - } + List repResult = getHashLookupResults(md5Hashes); + Map> partitioned = repResult.stream() + .filter(bean -> bean.getMalwareResult() != null) + .collect(Collectors.partitioningBy(bean -> bean.getMalwareResult().getStatus() == Status.FOUND)); + + // TODO handle caching list and creating new items + + createArtifacts(repResult, md5ToObjId); } catch (Exception ex) { notifyWarning( Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(), @@ -368,6 +325,65 @@ public class MalwareScanIngestModule implements FileIngestModule { } } + private void createArtifacts(List repResult, Map> md5ToObjId) throws Blackboard.BlackboardException, TskCoreException { + List 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 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); + } + } + } + + trans.commit(); + trans = null; + } finally { + if (trans != null) { + trans.rollback(); + createdArtifacts.clear(); + trans = null; + } + } + + if (!CollectionUtils.isEmpty(createdArtifacts)) { + tskCase.getBlackboard().postArtifacts(createdArtifacts, Bundle.MalwareScanIngestModuleFactory_displayName(), ingestJobId); + } + } + } + + private List getHashLookupResults(List md5Hashes) throws CTCloudException { + // get an auth token with the license + AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfo.getDecryptedLicense()); + + // make sure we are in bounds for the remaining scans + long remainingScans = remaining(authTokenResponse.getHashLookupLimit(), authTokenResponse.getHashLookupCount()); + if (remainingScans <= 0) { + runState = RunState.DISABLED; + notifyWarning( + Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_title(), + Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc(), + null); + return Collections.emptyList(); + } + + // using auth token, get results + return ctApiDAO.getReputationResults( + new AuthenticatedRequestData(licenseInfo.getDecryptedLicense(), authTokenResponse), + md5Hashes + ); + } + private String sanitizedMd5(String orig) { return StringUtils.defaultString(orig).trim().toLowerCase(); } @@ -392,7 +408,7 @@ public class MalwareScanIngestModule implements FileIngestModule { return false; } - AbstractFile af = skCase.getAbstractFileById(objId); + AbstractFile af = tskCase.getAbstractFileById(objId); if (af == null) { return false; } @@ -402,7 +418,7 @@ public class MalwareScanIngestModule implements FileIngestModule { } // get auth token / file upload url - AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(decrypted, true); + AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfo.getDecryptedLicense(), true); if (StringUtils.isBlank(authTokenResponse.getFileUploadUrl())) { throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR); } else if (remaining(authTokenResponse.getFileUploadLimit(), authTokenResponse.getFileUploadCount()) <= 0) { @@ -425,27 +441,29 @@ public class MalwareScanIngestModule implements FileIngestModule { .setSha1(af.getSha1Hash()) .setSha256(af.getSha256Hash()); - ctApiDAO.uploadMeta(new AuthenticatedRequestData(decrypted, authTokenResponse), metaRequest); + ctApiDAO.uploadMeta(new AuthenticatedRequestData(licenseInfo.getDecryptedLicense(), authTokenResponse), metaRequest); return true; } - private boolean getUploadedFileResults(Map> md5objIdMapping) { + private boolean getUploadedFileResults(Map> md5objIdMapping) throws InterruptedException, CTCloudException, Blackboard.BlackboardException, TskCoreException { + // TODO integrate this Map> remaining = new HashMap<>(md5objIdMapping); for (int retry = 0; retry < NUM_FILE_UPLOAD_RETRIES; retry++) { List> md5Batches = Lists.partition(new ArrayList<>(remaining.keySet()), BATCH_SIZE); for (List batch : md5Batches) { - // TODO query and capture still unknown + List repResult = getHashLookupResults(batch); + createArtifacts(repResult, remaining); } - + if (remaining.isEmpty()) { return true; } - - + Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS); } + return false; } @Messages({ From 5128468a9e1ebbb6fd8832974741b3d4fddc1a38 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Wed, 26 Jul 2023 08:36:03 -0400 Subject: [PATCH 08/32] work in ingest module --- .../malwarescan/MalwareScanIngestModule.java | 97 +++++++++++++++---- 1 file changed, 79 insertions(+), 18 deletions(-) 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 2a67b46792..b7ba39c0b7 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -31,6 +31,7 @@ import com.basistech.df.cybertriage.autopsy.ctapi.json.MetadataUploadRequest; import com.basistech.df.cybertriage.autopsy.ctoptions.ctcloud.CTLicensePersistence; import java.text.MessageFormat; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -100,6 +101,9 @@ public class MalwareScanIngestModule implements FileIngestModule { //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 = 60 * 5; @@ -123,8 +127,8 @@ public class MalwareScanIngestModule implements FileIngestModule { private static final String MALWARE_CONFIG = "Cyber Triage Cloud"; private static final Logger logger = Logger.getLogger(MalwareScanIngestModule.class.getName()); - private final BatchProcessor batchProcessor = new BatchProcessor(BATCH_SIZE, FLUSH_SECS_TIMEOUT, this::handleBatch); + private final BatchProcessor batchProcessor = new BatchProcessor(BATCH_SIZE, FLUSH_SECS_TIMEOUT, this::handleBatch); private final CTLicensePersistence ctSettingsPersistence = CTLicensePersistence.getInstance(); private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); @@ -137,19 +141,24 @@ public class MalwareScanIngestModule implements FileIngestModule { private BlackboardArtifact.Type malwareType = null; private long dsId = 0; private long ingestJobId = 0; + private boolean uploadUnknownFiles = false; private Map> unidentifiedHashes = 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. Cyber Triage processing 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) { @@ -175,18 +184,36 @@ public class MalwareScanIngestModule implements FileIngestModule { long lookupsRemaining = remaining(authTokenResponse.getHashLookupLimit(), authTokenResponse.getHashLookupCount()); if (lookupsRemaining <= 0) { notifyWarning( - Bundle.MalwareScanIngestModule_ShareProcessing_noRemaining_title(), - Bundle.MalwareScanIngestModule_ShareProcessing_noRemaining_desc(), + Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_title(), + Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc(), null); runState = RunState.DISABLED; return; } else if (lookupsRemaining < LOW_LOOKUPS_REMAINING) { notifyWarning( - Bundle.MalwareScanIngestModule_ShareProcessing_lowLimitWarning_title(), - Bundle.MalwareScanIngestModule_ShareProcessing_lowLimitWarning_desc(lookupsRemaining), + 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_noUploadsRemaining_title(), + Bundle.MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_desc(), + null); + uploadFiles = false; + } else if (lookupsRemaining < LOW_UPLOADS_REMAINING) { + notifyWarning( + 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( @@ -197,9 +224,9 @@ public class MalwareScanIngestModule implements FileIngestModule { dsId = context.getDataSource().getId(); ingestJobId = context.getJobId(); licenseInfo = licenseInfoOpt.get(); - uploadUnknownFiles = ctSettingsPersistence.loadMalwareIngestSettings().isUploadFiles(); + uploadUnknownFiles = uploadFiles; unidentifiedHashes = new HashMap<>(); - + // set run state to initialized runState = RunState.STARTED_UP; } catch (Exception ex) { @@ -310,13 +337,28 @@ public class MalwareScanIngestModule implements FileIngestModule { try { List repResult = getHashLookupResults(md5Hashes); - Map> partitioned = repResult.stream() + Map> statusGroupings = repResult.stream() .filter(bean -> bean.getMalwareResult() != null) - .collect(Collectors.partitioningBy(bean -> bean.getMalwareResult().getStatus() == Status.FOUND)); - - // TODO handle caching list and creating new items - + .collect(Collectors.groupingBy(bean -> bean.getMalwareResult().getStatus())); + + List found = statusGroupings.get(Status.FOUND); createArtifacts(repResult, md5ToObjId); + + // if being scanned, check list to run later + List beingScannedList = statusGroupings.get(Status.BEING_SCANNED); + + // if not found, try upload + List notFound = statusGroupings.get(Status.NOT_FOUND); + + if (CollectionUtils.isNotEmpty(statusGroupings.get(Status.ERROR))) { + + } + + if (CollectionUtils.isNotEmpty(statusGroupings.get(Status.LIMITS_EXCEEDED))) { + + } + // TODO handle caching list and creating new items + } catch (Exception ex) { notifyWarning( Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(), @@ -325,6 +367,25 @@ public class MalwareScanIngestModule implements FileIngestModule { } } + private void processMissing(Collection results, Map> md5ToObjId, boolean doFileUpload) throws CTCloudException { + for (CTCloudBean beingScanned : CollectionUtils.emptyIfNull(results)) { + + String sanitizedMd5 = sanitizedMd5(beingScanned.getMd5HashValue()); + if (StringUtils.isBlank(sanitizedMd5)) { + continue; + } + List correspondingObjIds = md5ToObjId.get(sanitizedMd5); + if (CollectionUtils.isEmpty(correspondingObjIds)) { + continue; + } + + if (doFileUpload) { + uploadFile(beingScanned, correspondingObjIds.get(0)); + } + this.unidentifiedHashes.put(sanitizedMd5, correspondingObjIds); + } + } + private void createArtifacts(List repResult, Map> md5ToObjId) throws Blackboard.BlackboardException, TskCoreException { List createdArtifacts = new ArrayList<>(); if (!CollectionUtils.isEmpty(repResult)) { From 10ae3411ac376c9d48fc6bd6b6f5ece606519403 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Wed, 26 Jul 2023 16:02:43 -0400 Subject: [PATCH 09/32] refactoring --- .../malwarescan/MalwareScanIngestModule.java | 166 ++++++++++++++---- 1 file changed, 127 insertions(+), 39 deletions(-) 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 b7ba39c0b7..13877d701d 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -43,9 +43,11 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; 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.casemodule.NoCurrentCaseException; import org.sleuthkit.autopsy.coreutils.Logger; import org.sleuthkit.autopsy.coreutils.MessageNotifyUtil; import org.sleuthkit.autopsy.ingest.FileIngestModule; @@ -133,17 +135,8 @@ public class MalwareScanIngestModule implements FileIngestModule { private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); // TODO minimize state - 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 boolean uploadUnknownFiles = false; - private Map> unidentifiedHashes = null; +// private RunState runState = null; +// private IngestJobState ingestJobState = null; @Messages({ "MalwareScanIngestModule_malwareTypeDisplayName=Malware", @@ -166,15 +159,30 @@ public class MalwareScanIngestModule implements FileIngestModule { } try { - // get saved license + Pair jobStateResult = getNewJobState(context); + runState = jobStateResult.getLeft(); + ingestJobState = jobStateResult.getRight(); + } catch (Exception ex) { + runState = RunState.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 Pair getNewJobState(IngestJobContext context) throws Exception { + // get saved license Optional 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; + return Pair.of(RunState.DISABLED, null); } AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfoOpt.get().getDecryptedLicense()); @@ -187,8 +195,7 @@ public class MalwareScanIngestModule implements FileIngestModule { Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_title(), Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc(), null); - runState = RunState.DISABLED; - return; + return Pair.of(RunState.DISABLED, null); } else if (lookupsRemaining < LOW_LOOKUPS_REMAINING) { notifyWarning( Bundle.MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_title(), @@ -215,33 +222,42 @@ public class MalwareScanIngestModule implements FileIngestModule { } // setup necessary variables for processing - tskCase = Case.getCurrentCaseThrows().getSleuthkitCase(); - malwareType = tskCase.getBlackboard().getOrAddArtifactType( + SleuthkitCase tskCase = Case.getCurrentCaseThrows().getSleuthkitCase(); + BlackboardArtifact.Type 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(); - uploadUnknownFiles = uploadFiles; - unidentifiedHashes = new HashMap<>(); - - // 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); - } + + IngestJobState ingestJobState = new IngestJobState( + tskCase, + new FileTypeDetector(), + licenseInfoOpt.get(), + malwareType, + context.getDataSource().getId(), + context.getJobId(), + uploadFiles); + + return Pair.of(RunState.STARTED_UP, ingestJobState); } + /** + * 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) { + /** + * 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) { if (StringUtils.isNotBlank(af.getMd5Hash())) { return af.getMd5Hash(); } @@ -342,22 +358,29 @@ public class MalwareScanIngestModule implements FileIngestModule { .collect(Collectors.groupingBy(bean -> bean.getMalwareResult().getStatus())); List found = statusGroupings.get(Status.FOUND); - createArtifacts(repResult, md5ToObjId); + createArtifacts(found, md5ToObjId); // if being scanned, check list to run later List beingScannedList = statusGroupings.get(Status.BEING_SCANNED); - + processMissing(beingScannedList, md5ToObjId, false); + // if not found, try upload List notFound = statusGroupings.get(Status.NOT_FOUND); + processMissing(notFound, md5ToObjId, true); if (CollectionUtils.isNotEmpty(statusGroupings.get(Status.ERROR))) { - + notifyWarning( + Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(), + Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc(), + null); } if (CollectionUtils.isNotEmpty(statusGroupings.get(Status.LIMITS_EXCEEDED))) { - + notifyWarning( + Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_title(), + Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc(), + null); } - // TODO handle caching list and creating new items } catch (Exception ex) { notifyWarning( @@ -367,7 +390,7 @@ public class MalwareScanIngestModule implements FileIngestModule { } } - private void processMissing(Collection results, Map> md5ToObjId, boolean doFileUpload) throws CTCloudException { + private static void processMissing(Collection results, Map> md5ToObjId, boolean doFileUpload) throws CTCloudException, TskCoreException { for (CTCloudBean beingScanned : CollectionUtils.emptyIfNull(results)) { String sanitizedMd5 = sanitizedMd5(beingScanned.getMd5HashValue()); @@ -507,7 +530,6 @@ public class MalwareScanIngestModule implements FileIngestModule { } private boolean getUploadedFileResults(Map> md5objIdMapping) throws InterruptedException, CTCloudException, Blackboard.BlackboardException, TskCoreException { - // TODO integrate this Map> remaining = new HashMap<>(md5objIdMapping); for (int retry = 0; retry < NUM_FILE_UPLOAD_RETRIES; retry++) { @@ -571,11 +593,18 @@ public class MalwareScanIngestModule implements FileIngestModule { // flush any remaining items try { batchProcessor.flushAndReset(); + + getUploadedFileResults(this.unidentifiedHashes); } 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; @@ -610,5 +639,64 @@ public class MalwareScanIngestModule implements FileIngestModule { } } + + class IngestJobState { + 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 Map> unidentifiedHashes = new HashMap<>(); + + // this can change mid run + private boolean uploadUnknownFiles; + + IngestJobState(SleuthkitCase tskCase, FileTypeDetector fileTypeDetector, LicenseInfo licenseInfo, BlackboardArtifact.Type malwareType, long dsId, long ingestJobId, boolean uploadUnknownFiles) { + this.tskCase = tskCase; + this.fileTypeDetector = fileTypeDetector; + this.licenseInfo = licenseInfo; + this.malwareType = malwareType; + this.dsId = dsId; + this.ingestJobId = ingestJobId; + this.uploadUnknownFiles = uploadUnknownFiles; + } + + SleuthkitCase getTskCase() { + return tskCase; + } + + FileTypeDetector getFileTypeDetector() { + return fileTypeDetector; + } + + LicenseInfo getLicenseInfo() { + return licenseInfo; + } + + BlackboardArtifact.Type getMalwareType() { + return malwareType; + } + + long getDsId() { + return dsId; + } + + long getIngestJobId() { + return ingestJobId; + } + + Map> getUnidentifiedHashes() { + return unidentifiedHashes; + } + + boolean uploadUnknownFiles() { + return uploadUnknownFiles; + } + + void setUploadUnknownFiles(boolean uploadUnknownFiles) { + this.uploadUnknownFiles = uploadUnknownFiles; + } + } } } From 19385ef3f82f8ed7f8fa7d3822fab54038b2f5ae Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Wed, 26 Jul 2023 20:42:37 -0400 Subject: [PATCH 10/32] refactoring and commenting --- .../malwarescan/MalwareScanIngestModule.java | 309 +++++++++++------- 1 file changed, 192 insertions(+), 117 deletions(-) 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 13877d701d..c982220e8b 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -62,6 +62,7 @@ import org.sleuthkit.datamodel.ReadContentInputStream; import org.sleuthkit.datamodel.HashUtility; import org.sleuthkit.datamodel.HashUtility.HashResult; import org.sleuthkit.datamodel.HashUtility.HashType; +import org.sleuthkit.datamodel.IngestJobInfo; import org.sleuthkit.datamodel.Score; import org.sleuthkit.datamodel.SleuthkitCase; import org.sleuthkit.datamodel.TskCoreException; @@ -137,7 +138,6 @@ public class MalwareScanIngestModule implements FileIngestModule { // TODO minimize state // private RunState runState = null; // private IngestJobState ingestJobState = null; - @Messages({ "MalwareScanIngestModule_malwareTypeDisplayName=Malware", "MalwareScanIngestModule_ShareProcessing_noLicense_title=No Cyber Triage License", @@ -167,81 +167,84 @@ public class MalwareScanIngestModule implements FileIngestModule { 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 + * @return A pair of the runtime state (i.e. started up, disabled) and + * parameters required for the job. + * @throws Exception */ private Pair getNewJobState(IngestJobContext context) throws Exception { - // get saved license - Optional licenseInfoOpt = ctSettingsPersistence.loadLicenseInfo(); - if (licenseInfoOpt.isEmpty() || licenseInfoOpt.get().getDecryptedLicense() == null) { - notifyWarning( - Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_title(), - Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_desc(), - null); - return Pair.of(RunState.DISABLED, null); - } + // get saved license + Optional licenseInfoOpt = ctSettingsPersistence.loadLicenseInfo(); + if (licenseInfoOpt.isEmpty() || licenseInfoOpt.get().getDecryptedLicense() == null) { + notifyWarning( + Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_title(), + Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_desc(), + null); + return Pair.of(RunState.DISABLED, null); + } - 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 Pair.of(RunState.DISABLED, null); + } 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_noLookupsRemaining_title(), - Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc(), + Bundle.MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_title(), + Bundle.MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_desc(), null); - return Pair.of(RunState.DISABLED, null); - } else if (lookupsRemaining < LOW_LOOKUPS_REMAINING) { + uploadFiles = false; + } else if (lookupsRemaining < LOW_UPLOADS_REMAINING) { notifyWarning( - Bundle.MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_title(), - Bundle.MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_desc(lookupsRemaining), + Bundle.MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_title(), + Bundle.MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_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_noUploadsRemaining_title(), - Bundle.MalwareScanIngestModule_ShareProcessing_noUploadsRemaining_desc(), - null); - uploadFiles = false; - } else if (lookupsRemaining < LOW_UPLOADS_REMAINING) { - notifyWarning( - Bundle.MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_title(), - Bundle.MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_desc(lookupsRemaining), - null); - } - } + // 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); - // 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); - - IngestJobState ingestJobState = new IngestJobState( - tskCase, - new FileTypeDetector(), - licenseInfoOpt.get(), - malwareType, - context.getDataSource().getId(), - context.getJobId(), - uploadFiles); - - return Pair.of(RunState.STARTED_UP, ingestJobState); + IngestJobState ingestJobState = new IngestJobState( + tskCase, + new FileTypeDetector(), + licenseInfoOpt.get(), + malwareType, + context.getDataSource().getId(), + context.getJobId(), + uploadFiles); + + return Pair.of(RunState.STARTED_UP, ingestJobState); } /** * 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. @@ -254,6 +257,7 @@ public class MalwareScanIngestModule implements FileIngestModule { /** * 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). */ @@ -282,6 +286,7 @@ public class MalwareScanIngestModule implements FileIngestModule { return null; } + // TODO return to this @Messages({ "MalwareScanIngestModule_ShareProcessing_batchTimeout_title=Batch Processing Timeout", "MalwareScanIngestModule_ShareProcessing_batchTimeout_desc=Batch processing timed out" @@ -314,6 +319,7 @@ public class MalwareScanIngestModule implements FileIngestModule { } } + // TODO return to this @Messages({ "MalwareScanIngestModule_SharedProcessing_authTokenResponseError_title=Authentication API error", "# {0} - errorResponse", @@ -338,7 +344,7 @@ public class MalwareScanIngestModule implements FileIngestModule { continue; } - String sanitizedMd5 = sanitizedMd5(fr.getMd5hash()); + String sanitizedMd5 = normalizedMd5(fr.getMd5hash()); md5ToObjId .computeIfAbsent(sanitizedMd5, (k) -> new ArrayList<>()) .add(fr.getObjId()); @@ -363,7 +369,7 @@ public class MalwareScanIngestModule implements FileIngestModule { // if being scanned, check list to run later List beingScannedList = statusGroupings.get(Status.BEING_SCANNED); processMissing(beingScannedList, md5ToObjId, false); - + // if not found, try upload List notFound = statusGroupings.get(Status.NOT_FOUND); processMissing(notFound, md5ToObjId, true); @@ -390,10 +396,11 @@ public class MalwareScanIngestModule implements FileIngestModule { } } - private static void processMissing(Collection results, Map> md5ToObjId, boolean doFileUpload) throws CTCloudException, TskCoreException { + // TODO return to this + private void processMissing(Collection results, Map> md5ToObjId, boolean doFileUpload) throws CTCloudException, TskCoreException { for (CTCloudBean beingScanned : CollectionUtils.emptyIfNull(results)) { - String sanitizedMd5 = sanitizedMd5(beingScanned.getMd5HashValue()); + String sanitizedMd5 = normalizedMd5(beingScanned.getMd5HashValue()); if (StringUtils.isBlank(sanitizedMd5)) { continue; } @@ -409,43 +416,7 @@ public class MalwareScanIngestModule implements FileIngestModule { } } - private void createArtifacts(List repResult, Map> md5ToObjId) throws Blackboard.BlackboardException, TskCoreException { - List 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 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); - } - } - } - - trans.commit(); - trans = null; - } finally { - if (trans != null) { - trans.rollback(); - createdArtifacts.clear(); - trans = null; - } - } - - if (!CollectionUtils.isEmpty(createdArtifacts)) { - tskCase.getBlackboard().postArtifacts(createdArtifacts, Bundle.MalwareScanIngestModuleFactory_displayName(), ingestJobId); - } - } - } - + // TODO return to this private List getHashLookupResults(List md5Hashes) throws CTCloudException { // get an auth token with the license AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfo.getDecryptedLicense()); @@ -468,23 +439,43 @@ public class MalwareScanIngestModule implements FileIngestModule { ); } - private String sanitizedMd5(String orig) { + /** + * 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(); } - private boolean isUnknown(CTCloudBean cloudBean) { + /** + * Returns true if the cloud bean indicates that the file is not + * currently known by CT cloud. + * + * @param cloudBean The cloud bean. + * @return True if not known by CT cloud. + */ + private static boolean isUnknown(CTCloudBean cloudBean) { return cloudBean != null && cloudBean.getMalwareResult() != null && cloudBean.getMalwareResult().getStatus() == MalwareResultBean.Status.NOT_FOUND; } - private boolean isUploadable(AbstractFile af) { + /** + * 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; } - private boolean uploadFile(CTCloudBean cloudBean, long objId) throws CTCloudException, TskCoreException { - if (!uploadUnknownFiles) { + // TODO return to this + private boolean uploadFile(IngestJobState ingestJobState, CTCloudBean cloudBean, long objId) throws CTCloudException, TskCoreException { + if (!ingestJobState.uploadUnknownFiles()) { return false; } @@ -492,7 +483,7 @@ public class MalwareScanIngestModule implements FileIngestModule { return false; } - AbstractFile af = tskCase.getAbstractFileById(objId); + AbstractFile af = ingestJobState.getTskCase().getAbstractFileById(objId); if (af == null) { return false; } @@ -502,12 +493,12 @@ public class MalwareScanIngestModule implements FileIngestModule { } // get auth token / file upload url - AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfo.getDecryptedLicense(), true); + 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 - uploadUnknownFiles = false; + ingestJobState.setUploadUnknownFiles(false); return false; } @@ -525,10 +516,11 @@ public class MalwareScanIngestModule implements FileIngestModule { .setSha1(af.getSha1Hash()) .setSha256(af.getSha256Hash()); - ctApiDAO.uploadMeta(new AuthenticatedRequestData(licenseInfo.getDecryptedLicense(), authTokenResponse), metaRequest); + ctApiDAO.uploadMeta(new AuthenticatedRequestData(ingestJobState.getLicenseInfo().getDecryptedLicense(), authTokenResponse), metaRequest); return true; } + // TODO return to this private boolean getUploadedFileResults(Map> md5objIdMapping) throws InterruptedException, CTCloudException, Blackboard.BlackboardException, TskCoreException { Map> remaining = new HashMap<>(md5objIdMapping); @@ -549,12 +541,83 @@ public class MalwareScanIngestModule implements FileIngestModule { return false; } + /** + * 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 + * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException + * @throws TskCoreException + */ + private void createAnalysisResults(IngestJobState ingestJobState, List repResult, Map> md5ToObjId) throws Blackboard.BlackboardException, TskCoreException { + List createdArtifacts = new ArrayList<>(); + if (!CollectionUtils.isEmpty(repResult)) { + SleuthkitCase.CaseDbTransaction trans = null; + try { + trans = ingestJobState.getTskCase().beginTransaction(); + for (CTCloudBean result : repResult) { + String sanitizedMd5 = normalizedMd5(result.getMd5HashValue()); + List objIds = md5ToObjId.remove(sanitizedMd5); + if (objIds == null || objIds.isEmpty()) { + 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 + ? "" + : objId, + (cloudBean == null || cloudBean.getMalwareResult() == null || cloudBean.getMalwareResult().getStatus() == null) + ? "" + : cloudBean.getMalwareResult().getStatus().name() + )); return null; } @@ -568,10 +631,10 @@ public class MalwareScanIngestModule implements FileIngestModule { 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, @@ -580,6 +643,9 @@ public class MalwareScanIngestModule implements FileIngestModule { 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" @@ -593,7 +659,7 @@ public class MalwareScanIngestModule implements FileIngestModule { // flush any remaining items try { batchProcessor.flushAndReset(); - + getUploadedFileResults(this.unidentifiedHashes); } catch (InterruptedException ex) { notifyWarning( @@ -611,7 +677,15 @@ public class MalwareScanIngestModule implements FileIngestModule { } } - 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); } @@ -639,8 +713,9 @@ public class MalwareScanIngestModule implements FileIngestModule { } } - + class IngestJobState { + private final SleuthkitCase tskCase; private final FileTypeDetector fileTypeDetector; private final LicenseInfo licenseInfo; @@ -648,7 +723,7 @@ public class MalwareScanIngestModule implements FileIngestModule { private final long dsId; private final long ingestJobId; private final Map> unidentifiedHashes = new HashMap<>(); - + // this can change mid run private boolean uploadUnknownFiles; From 9ca09c5acfe95d515a69de2f9d4ac607a6909a24 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 12:28:50 -0400 Subject: [PATCH 11/32] revamp of malware ingest module --- .../malwarescan/MalwareScanIngestModule.java | 434 +++++++++++------- 1 file changed, 275 insertions(+), 159 deletions(-) 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 c982220e8b..8db2ba70e8 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -23,15 +23,12 @@ 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.DecryptedLicenseResponse; import com.basistech.df.cybertriage.autopsy.ctapi.json.LicenseInfo; -import com.basistech.df.cybertriage.autopsy.ctapi.json.MalwareResultBean; 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.text.MessageFormat; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -42,12 +39,11 @@ import java.util.logging.Level; 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.commons.lang3.tuple.Pair; 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.casemodule.NoCurrentCaseException; import org.sleuthkit.autopsy.coreutils.Logger; import org.sleuthkit.autopsy.coreutils.MessageNotifyUtil; import org.sleuthkit.autopsy.ingest.FileIngestModule; @@ -62,7 +58,6 @@ import org.sleuthkit.datamodel.ReadContentInputStream; import org.sleuthkit.datamodel.HashUtility; import org.sleuthkit.datamodel.HashUtility.HashResult; import org.sleuthkit.datamodel.HashUtility.HashType; -import org.sleuthkit.datamodel.IngestJobInfo; import org.sleuthkit.datamodel.Score; import org.sleuthkit.datamodel.SleuthkitCase; import org.sleuthkit.datamodel.TskCoreException; @@ -109,7 +104,7 @@ public class MalwareScanIngestModule implements FileIngestModule { 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 = 60 * 5; + private static final int NUM_FILE_UPLOAD_RETRIES = 7; private static final long FILE_UPLOAD_RETRY_SLEEP_MILLIS = 60 * 1000; private static final Set EXECUTABLE_MIME_TYPES = Stream.of( @@ -131,13 +126,16 @@ public class MalwareScanIngestModule implements FileIngestModule { private static final Logger logger = Logger.getLogger(MalwareScanIngestModule.class.getName()); - private final BatchProcessor batchProcessor = new BatchProcessor(BATCH_SIZE, FLUSH_SECS_TIMEOUT, this::handleBatch); + private final BatchProcessor batchProcessor = new BatchProcessor( + 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(); - // TODO minimize state -// private RunState runState = null; -// private IngestJobState ingestJobState = null; + private IngestJobState ingestJobState = null; + @Messages({ "MalwareScanIngestModule_malwareTypeDisplayName=Malware", "MalwareScanIngestModule_ShareProcessing_noLicense_title=No Cyber Triage License", @@ -154,16 +152,14 @@ public class MalwareScanIngestModule implements FileIngestModule { "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 { - Pair jobStateResult = getNewJobState(context); - runState = jobStateResult.getLeft(); - ingestJobState = jobStateResult.getRight(); + ingestJobState = getNewJobState(context); } catch (Exception ex) { - runState = RunState.DISABLED; + ingestJobState = IngestJobState.DISABLED; throw new IngestModuleException("An exception occurred on MalwareScanIngestModule startup", ex); } } @@ -176,7 +172,7 @@ public class MalwareScanIngestModule implements FileIngestModule { * parameters required for the job. * @throws Exception */ - private Pair getNewJobState(IngestJobContext context) throws Exception { + private IngestJobState getNewJobState(IngestJobContext context) throws Exception { // get saved license Optional licenseInfoOpt = ctSettingsPersistence.loadLicenseInfo(); if (licenseInfoOpt.isEmpty() || licenseInfoOpt.get().getDecryptedLicense() == null) { @@ -184,7 +180,8 @@ public class MalwareScanIngestModule implements FileIngestModule { Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_title(), Bundle.MalwareScanIngestModule_ShareProcessing_noLicense_desc(), null); - return Pair.of(RunState.DISABLED, null); + + return IngestJobState.DISABLED; } AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfoOpt.get().getDecryptedLicense()); @@ -197,7 +194,8 @@ public class MalwareScanIngestModule implements FileIngestModule { Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_title(), Bundle.MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc(), null); - return Pair.of(RunState.DISABLED, null); + + return IngestJobState.DISABLED; } else if (lookupsRemaining < LOW_LOOKUPS_REMAINING) { notifyWarning( Bundle.MalwareScanIngestModule_ShareProcessing_lowLookupsLimitWarning_title(), @@ -230,16 +228,16 @@ public class MalwareScanIngestModule implements FileIngestModule { Bundle.MalwareScanIngestModule_malwareTypeDisplayName(), BlackboardArtifact.Category.ANALYSIS_RESULT); - IngestJobState ingestJobState = new IngestJobState( + return new IngestJobState( tskCase, new FileTypeDetector(), licenseInfoOpt.get(), malwareType, context.getDataSource().getId(), context.getJobId(), - uploadFiles); - - return Pair.of(RunState.STARTED_UP, ingestJobState); + uploadFiles, + true + ); } /** @@ -286,17 +284,26 @@ public class MalwareScanIngestModule implements FileIngestModule { return null; } - // TODO return to this + /** + * 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() && 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); if (StringUtils.isNotBlank(md5)) { @@ -319,7 +326,13 @@ public class MalwareScanIngestModule implements FileIngestModule { } } - // TODO return to this + /** + * 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", @@ -331,8 +344,8 @@ public class MalwareScanIngestModule implements FileIngestModule { "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 fileRecords) { - if (runState != RunState.STARTED_UP || fileRecords == null || fileRecords.isEmpty()) { + private void handleBatch(IngestJobState ingestJobState, List fileRecords) { + if (ingestJobState == null || !ingestJobState.isDoFileLookups() || fileRecords == null || fileRecords.isEmpty()) { return; } @@ -348,7 +361,6 @@ public class MalwareScanIngestModule implements FileIngestModule { md5ToObjId .computeIfAbsent(sanitizedMd5, (k) -> new ArrayList<>()) .add(fr.getObjId()); - } List md5Hashes = new ArrayList<>(md5ToObjId.keySet()); @@ -358,36 +370,8 @@ public class MalwareScanIngestModule implements FileIngestModule { } try { - List repResult = getHashLookupResults(md5Hashes); - Map> statusGroupings = repResult.stream() - .filter(bean -> bean.getMalwareResult() != null) - .collect(Collectors.groupingBy(bean -> bean.getMalwareResult().getStatus())); - - List found = statusGroupings.get(Status.FOUND); - createArtifacts(found, md5ToObjId); - - // if being scanned, check list to run later - List beingScannedList = statusGroupings.get(Status.BEING_SCANNED); - processMissing(beingScannedList, md5ToObjId, false); - - // if not found, try upload - List notFound = statusGroupings.get(Status.NOT_FOUND); - processMissing(notFound, md5ToObjId, true); - - if (CollectionUtils.isNotEmpty(statusGroupings.get(Status.ERROR))) { - notifyWarning( - Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(), - Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc(), - null); - } - - if (CollectionUtils.isNotEmpty(statusGroupings.get(Status.LIMITS_EXCEEDED))) { - notifyWarning( - Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_title(), - Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc(), - null); - } - + List repResult = getHashLookupResults(ingestJobState, md5Hashes); + handleLookupResults(ingestJobState, md5ToObjId, repResult); } catch (Exception ex) { notifyWarning( Bundle.MalwareScanIngestModule_SharedProcessing_generalProcessingError_title(), @@ -396,35 +380,110 @@ public class MalwareScanIngestModule implements FileIngestModule { } } - // TODO return to this - private void processMissing(Collection results, Map> md5ToObjId, boolean doFileUpload) throws CTCloudException, TskCoreException { - for (CTCloudBean beingScanned : CollectionUtils.emptyIfNull(results)) { + /** + * 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 + * @throws TskCoreException + */ + @Messages({ + "MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_title=Some Lookup Results Not Processed", + "MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_desc=Some lookup results were not processed due to exceeding limits. Please try again later.",}) + private void handleLookupResults(IngestJobState ingestJobState, Map> md5ToObjId, List repResult) throws Blackboard.BlackboardException, TskCoreException, TskCoreException, CTCloudException { + if (CollectionUtils.isEmpty(repResult)) { + return; + } - String sanitizedMd5 = normalizedMd5(beingScanned.getMd5HashValue()); - if (StringUtils.isBlank(sanitizedMd5)) { - continue; - } - List correspondingObjIds = md5ToObjId.get(sanitizedMd5); - if (CollectionUtils.isEmpty(correspondingObjIds)) { - continue; - } + Map> statusGroupings = repResult.stream() + .filter(bean -> bean.getMalwareResult() != null) + .collect(Collectors.groupingBy(bean -> bean.getMalwareResult().getStatus())); - if (doFileUpload) { - uploadFile(beingScanned, correspondingObjIds.get(0)); - } - this.unidentifiedHashes.put(sanitizedMd5, correspondingObjIds); + // for all found items, create analysis results + List 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); } } - // TODO return to this - private List getHashLookupResults(List md5Hashes) throws CTCloudException { + /** + * 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> md5ToObjId, List results, boolean performFileUpload) throws CTCloudException, TskCoreException { + 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 correspondingObjIds = md5ToObjId.get(sanitizedMd5); + if (CollectionUtils.isEmpty(correspondingObjIds)) { + continue; + } + + if (performFileUpload) { + uploadFile(ingestJobState, correspondingObjIds.get(0)); + } + + 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 getHashLookupResults(IngestJobState ingestJobState, List md5Hashes) throws CTCloudException { // get an auth token with the license - AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(licenseInfo.getDecryptedLicense()); + 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) { - runState = RunState.DISABLED; + ingestJobState.disableDoFileLookups(); notifyWarning( Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_title(), Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc(), @@ -434,13 +493,14 @@ public class MalwareScanIngestModule implements FileIngestModule { // using auth token, get results return ctApiDAO.getReputationResults( - new AuthenticatedRequestData(licenseInfo.getDecryptedLicense(), authTokenResponse), + new AuthenticatedRequestData(ingestJobState.getLicenseInfo().getDecryptedLicense(), authTokenResponse), md5Hashes ); } /** * Normalizes an md5 string for the purposes of lookup in a map. + * * @param orig The original value. * @return The normalized value */ @@ -448,19 +508,6 @@ public class MalwareScanIngestModule implements FileIngestModule { return StringUtils.defaultString(orig).trim().toLowerCase(); } - /** - * Returns true if the cloud bean indicates that the file is not - * currently known by CT cloud. - * - * @param cloudBean The cloud bean. - * @return True if not known by CT cloud. - */ - private static boolean isUnknown(CTCloudBean cloudBean) { - return cloudBean != null - && cloudBean.getMalwareResult() != null - && cloudBean.getMalwareResult().getStatus() == MalwareResultBean.Status.NOT_FOUND; - } - /** * Whether or not an abstract file meets the requirements to be * uploaded. @@ -473,22 +520,22 @@ public class MalwareScanIngestModule implements FileIngestModule { return size >= MIN_UPLOAD_SIZE && size <= MAX_UPLOAD_SIZE; } - // TODO return to this - private boolean uploadFile(IngestJobState ingestJobState, CTCloudBean cloudBean, long objId) throws CTCloudException, TskCoreException { - if (!ingestJobState.uploadUnknownFiles()) { - return false; - } - - if (!isUnknown(cloudBean)) { + /** + * 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 + */ + private boolean uploadFile(IngestJobState ingestJobState, long objId) throws CTCloudException, TskCoreException { + if (!ingestJobState.isUploadUnknownFiles()) { return false; } AbstractFile af = ingestJobState.getTskCase().getAbstractFileById(objId); - if (af == null) { - return false; - } - - if (!isUploadable(af)) { + if (af == null || !isUploadable(af)) { return false; } @@ -498,7 +545,7 @@ public class MalwareScanIngestModule implements FileIngestModule { throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR); } else if (remaining(authTokenResponse.getFileUploadLimit(), authTokenResponse.getFileUploadCount()) <= 0) { // don't proceed with upload if reached limit - ingestJobState.setUploadUnknownFiles(false); + ingestJobState.disableUploadUnknownFiles(); return false; } @@ -520,25 +567,58 @@ public class MalwareScanIngestModule implements FileIngestModule { return true; } - // TODO return to this - private boolean getUploadedFileResults(Map> md5objIdMapping) throws InterruptedException, CTCloudException, Blackboard.BlackboardException, TskCoreException { - Map> remaining = new HashMap<>(md5objIdMapping); + /** + * + * @param ingestJobState + * @param md5objIdMapping + * @return + * @throws InterruptedException + * @throws CTCloudException + * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException + * @throws TskCoreException + */ + private void longPollForNotFound(IngestJobState ingestJobState) throws InterruptedException, CTCloudException, Blackboard.BlackboardException, TskCoreException { + if (!ingestJobState.isDoFileLookups() || !ingestJobState.isQueryForMissing() || MapUtils.isEmpty(ingestJobState.getUnidentifiedHashes())) { + return; + } + + // TODO notify as a part of status that we are long polling for any missing + Map> remaining = new HashMap<>(ingestJobState.getUnidentifiedHashes()); for (int retry = 0; retry < NUM_FILE_UPLOAD_RETRIES; retry++) { List> md5Batches = Lists.partition(new ArrayList<>(remaining.keySet()), BATCH_SIZE); for (List batch : md5Batches) { - List repResult = getHashLookupResults(batch); - createArtifacts(repResult, remaining); + // if we have exceeded limits, then we're done. + if (!ingestJobState.isDoFileLookups()) { + return; + } + + List repResult = getHashLookupResults(ingestJobState, batch); + + Map> statusGroupings = repResult.stream() + .filter(bean -> bean.getMalwareResult() != null) + .collect(Collectors.groupingBy(bean -> bean.getMalwareResult().getStatus())); + + // for all found items, create analysis results + List 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 : found) { + String normalizedMd5 = normalizedMd5(foundItem.getMd5HashValue()); + remaining.remove(normalizedMd5); + } } if (remaining.isEmpty()) { - return true; + return; } - Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS); + Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS * ((long) Math.pow(2, retry))); } - return false; + // TODO if we get here, notify that not all lookups performed } /** @@ -548,49 +628,52 @@ public class MalwareScanIngestModule implements FileIngestModule { * @param ingestJobState The ingest job state. * @param repResult The list of cloud beans. Only cloud beans with a * malware status - * @param md5ToObjId + * @param md5ToObjId The mapping of md5 * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException * @throws TskCoreException */ private void createAnalysisResults(IngestJobState ingestJobState, List repResult, Map> md5ToObjId) throws Blackboard.BlackboardException, TskCoreException { - List createdArtifacts = new ArrayList<>(); - if (!CollectionUtils.isEmpty(repResult)) { - SleuthkitCase.CaseDbTransaction trans = null; - try { - trans = ingestJobState.getTskCase().beginTransaction(); - for (CTCloudBean result : repResult) { - String sanitizedMd5 = normalizedMd5(result.getMd5HashValue()); - List objIds = md5ToObjId.remove(sanitizedMd5); - if (objIds == null || objIds.isEmpty()) { - continue; - } + if (CollectionUtils.isEmpty(repResult)) { + return; + } - for (Long objId : objIds) { - AnalysisResult res = createAnalysisResult(ingestJobState, trans, result, objId); - if (res != null) { - createdArtifacts.add(res); - } - } + List createdArtifacts = new ArrayList<>(); + SleuthkitCase.CaseDbTransaction trans = null; + try { + trans = ingestJobState.getTskCase().beginTransaction(); + for (CTCloudBean result : repResult) { + String sanitizedMd5 = normalizedMd5(result.getMd5HashValue()); + List objIds = md5ToObjId.remove(sanitizedMd5); + if (CollectionUtils.isEmpty(objIds)) { + continue; } - trans.commit(); - trans = null; - } finally { - if (trans != null) { - trans.rollback(); - createdArtifacts.clear(); - trans = null; + for (Long objId : objIds) { + AnalysisResult res = createAnalysisResult(ingestJobState, trans, result, objId); + if (res != null) { + createdArtifacts.add(res); + } } } - if (!CollectionUtils.isEmpty(createdArtifacts)) { - ingestJobState.getTskCase().getBlackboard().postArtifacts( - createdArtifacts, - Bundle.MalwareScanIngestModuleFactory_displayName(), - ingestJobState.getIngestJobId() - ); + 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() + ); + } + } /** @@ -652,15 +735,14 @@ public class MalwareScanIngestModule implements FileIngestModule { }) synchronized void shutDown() { // if already shut down, return - if (runState == RunState.SHUT_DOWN) { + if (ingestJobState == null) { return; } // flush any remaining items try { + longPollForNotFound(ingestJobState); batchProcessor.flushAndReset(); - - getUploadedFileResults(this.unidentifiedHashes); } catch (InterruptedException ex) { notifyWarning( Bundle.MalwareScanIngestModule_SharedProcessing_flushTimeout_title(), @@ -673,7 +755,7 @@ public class MalwareScanIngestModule implements FileIngestModule { ex); } finally { // set state to shut down and clear any remaining - runState = RunState.SHUT_DOWN; + ingestJobState = null; } } @@ -690,10 +772,6 @@ public class MalwareScanIngestModule implements FileIngestModule { logger.log(Level.WARNING, message, ex); } - private enum RunState { - STARTED_UP, DISABLED, SHUT_DOWN - } - class FileRecord { private final long objId; @@ -714,7 +792,28 @@ public class MalwareScanIngestModule implements FileIngestModule { } - class IngestJobState { + /** + * 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, + 0L, + 0L, + false, + false + ); private final SleuthkitCase tskCase; private final FileTypeDetector fileTypeDetector; @@ -722,19 +821,24 @@ public class MalwareScanIngestModule implements FileIngestModule { private final BlackboardArtifact.Type malwareType; private final long dsId; private final long ingestJobId; + private final boolean queryForMissing; private final Map> unidentifiedHashes = new HashMap<>(); // this can change mid run private boolean uploadUnknownFiles; + private boolean doFileLookups; - IngestJobState(SleuthkitCase tskCase, FileTypeDetector fileTypeDetector, LicenseInfo licenseInfo, BlackboardArtifact.Type malwareType, long dsId, long ingestJobId, boolean uploadUnknownFiles) { + IngestJobState(SleuthkitCase tskCase, FileTypeDetector fileTypeDetector, LicenseInfo licenseInfo, BlackboardArtifact.Type malwareType, long dsId, long ingestJobId, boolean uploadUnknownFiles, boolean doFileLookups) { this.tskCase = tskCase; this.fileTypeDetector = fileTypeDetector; this.licenseInfo = licenseInfo; this.malwareType = malwareType; this.dsId = dsId; this.ingestJobId = ingestJobId; + // 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() { @@ -765,12 +869,24 @@ public class MalwareScanIngestModule implements FileIngestModule { return unidentifiedHashes; } - boolean uploadUnknownFiles() { + boolean isQueryForMissing() { + return queryForMissing; + } + + boolean isUploadUnknownFiles() { return uploadUnknownFiles; } - void setUploadUnknownFiles(boolean uploadUnknownFiles) { - this.uploadUnknownFiles = uploadUnknownFiles; + void disableUploadUnknownFiles() { + this.uploadUnknownFiles = false; + } + + boolean isDoFileLookups() { + return doFileLookups; + } + + void disableDoFileLookups() { + this.doFileLookups = false; } } } From 346c03fb769f2e7b2d602807748730341af64602 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 13:24:19 -0400 Subject: [PATCH 12/32] resolving TODOs --- .../malwarescan/MalwareScanIngestModule.java | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) 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 8db2ba70e8..dc54656a15 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -229,12 +229,11 @@ public class MalwareScanIngestModule implements FileIngestModule { BlackboardArtifact.Category.ANALYSIS_RESULT); return new IngestJobState( + context, tskCase, new FileTypeDetector(), licenseInfoOpt.get(), malwareType, - context.getDataSource().getId(), - context.getJobId(), uploadFiles, true ); @@ -301,6 +300,7 @@ public class MalwareScanIngestModule implements FileIngestModule { try { if (ingestJobState != null && ingestJobState.isDoFileLookups() + && !ingestJobState.getIngestJobContext().fileIngestIsCancelled() && af.getKnown() != TskData.FileKnown.KNOWN && EXECUTABLE_MIME_TYPES.contains(StringUtils.defaultString(ingestJobState.getFileTypeDetector().getMIMEType(af)).trim().toLowerCase()) && CollectionUtils.isEmpty(af.getAnalysisResults(ingestJobState.getMalwareType()))) { @@ -345,7 +345,11 @@ public class MalwareScanIngestModule implements FileIngestModule { "MalwareScanIngestModule_SharedProcessing_generalProcessingError_title=Hash Lookup Error", "MalwareScanIngestModule_SharedProcessing_generalProcessingError_desc=An error occurred while processing hash lookup results",}) private void handleBatch(IngestJobState ingestJobState, List fileRecords) { - if (ingestJobState == null || !ingestJobState.isDoFileLookups() || fileRecords == null || fileRecords.isEmpty()) { + if (ingestJobState == null + || !ingestJobState.isDoFileLookups() + || ingestJobState.getIngestJobContext().fileIngestIsCancelled() + || fileRecords == null + || fileRecords.isEmpty()) { return; } @@ -477,6 +481,10 @@ public class MalwareScanIngestModule implements FileIngestModule { * @throws CTCloudException */ private List getHashLookupResults(IngestJobState ingestJobState, List md5Hashes) throws CTCloudException { + if (ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { + return Collections.emptyList(); + } + // get an auth token with the license AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(ingestJobState.getLicenseInfo().getDecryptedLicense()); @@ -489,6 +497,8 @@ public class MalwareScanIngestModule implements FileIngestModule { Bundle.MalwareScanIngestModule_SharedProcessing_exhaustedHashLookups_desc(), null); return Collections.emptyList(); + } else if (ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { + return Collections.emptyList(); } // using auth token, get results @@ -530,7 +540,7 @@ public class MalwareScanIngestModule implements FileIngestModule { * @throws TskCoreException */ private boolean uploadFile(IngestJobState ingestJobState, long objId) throws CTCloudException, TskCoreException { - if (!ingestJobState.isUploadUnknownFiles()) { + if (!ingestJobState.isUploadUnknownFiles() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { return false; } @@ -547,6 +557,8 @@ public class MalwareScanIngestModule implements FileIngestModule { // don't proceed with upload if reached limit ingestJobState.disableUploadUnknownFiles(); return false; + } else if (ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { + return false; } // upload bytes @@ -577,19 +589,28 @@ public class MalwareScanIngestModule implements FileIngestModule { * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException * @throws TskCoreException */ + @Messages({ + "MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_title=Waiting for File Upload Results", + "MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_desc=Currently waiting for all files uploaded to Cyber Triage to be processed before finalizing results.", + "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())) { return; } - // TODO notify as a part of status that we are long polling for any missing + MessageNotifyUtil.Notify.info( + Bundle.MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_title(), + Bundle.MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_desc() + ); + Map> remaining = new HashMap<>(ingestJobState.getUnidentifiedHashes()); for (int retry = 0; retry < NUM_FILE_UPLOAD_RETRIES; retry++) { List> md5Batches = Lists.partition(new ArrayList<>(remaining.keySet()), BATCH_SIZE); for (List batch : md5Batches) { - // if we have exceeded limits, then we're done. - if (!ingestJobState.isDoFileLookups()) { + // if we have exceeded limits or cancelled, then we're done. + if (!ingestJobState.isDoFileLookups() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { return; } @@ -615,10 +636,15 @@ public class MalwareScanIngestModule implements FileIngestModule { return; } + // exponential backoff before trying again Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS * ((long) Math.pow(2, retry))); } - // TODO if we get here, notify that not all lookups performed + notifyWarning( + Bundle.MalwareScanIngestModule_longPollForNotFound_timeout_title(), + Bundle.MalwareScanIngestModule_longPollForNotFound_timeout_desc(), + null + ); } /** @@ -809,8 +835,7 @@ public class MalwareScanIngestModule implements FileIngestModule { null, null, null, - 0L, - 0L, + null, false, false ); @@ -827,14 +852,16 @@ public class MalwareScanIngestModule implements FileIngestModule { // this can change mid run private boolean uploadUnknownFiles; private boolean doFileLookups; + private final IngestJobContext ingestJobContext; - IngestJobState(SleuthkitCase tskCase, FileTypeDetector fileTypeDetector, LicenseInfo licenseInfo, BlackboardArtifact.Type malwareType, long dsId, long ingestJobId, boolean uploadUnknownFiles, boolean doFileLookups) { + IngestJobState(IngestJobContext ingestJobContext, SleuthkitCase tskCase, FileTypeDetector fileTypeDetector, LicenseInfo licenseInfo, BlackboardArtifact.Type malwareType, boolean uploadUnknownFiles, boolean doFileLookups) { this.tskCase = tskCase; this.fileTypeDetector = fileTypeDetector; this.licenseInfo = licenseInfo; this.malwareType = malwareType; - this.dsId = dsId; - this.ingestJobId = ingestJobId; + 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; @@ -845,6 +872,10 @@ public class MalwareScanIngestModule implements FileIngestModule { return tskCase; } + IngestJobContext getIngestJobContext() { + return ingestJobContext; + } + FileTypeDetector getFileTypeDetector() { return fileTypeDetector; } From 9a702f5ccab1e64ce4726a29347e9fe1b0d96dbd Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 13:47:49 -0400 Subject: [PATCH 13/32] fixes --- .../ctcloud/Bundle.properties-MERGED | 2 + .../ctcloud/CTMalwareScannerOptionsPanel.form | 94 +++++++++---------- .../ctcloud/CTMalwareScannerOptionsPanel.java | 64 ++++++------- .../malwarescan/Bundle.properties-MERGED | 19 +++- 4 files changed, 92 insertions(+), 87 deletions(-) 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 58a1befb1f..376ddfa415 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 @@ -56,3 +56,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 executable if executable is unknown +CTMalwareScannerOptionsPanel.fileUploadPanel.border.title=File Upload 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 8f5020d9b9..8557f946d9 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 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -195,54 +238,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 34db819373..5dd3a1db66 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 @@ -218,6 +218,8 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { 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(); @@ -231,11 +233,38 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { countersResetLabel = new javax.swing.JLabel(); hashLookupsRemainingLabel = new javax.swing.JLabel(); fileUploadsRemainingLabel = new javax.swing.JLabel(); - javax.swing.JPanel fileUploadPanel = new javax.swing.JPanel(); - fileUploadCheckbox = new javax.swing.JCheckBox(); 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()); @@ -365,37 +394,6 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { gridBagConstraints.anchor = java.awt.GridBagConstraints.NORTHWEST; gridBagConstraints.weightx = 1.0; add(malwareScansPanel, gridBagConstraints); - - 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, 32767)); - fileUploadCheckbox.setMinimumSize(new java.awt.Dimension(255, 50)); - fileUploadCheckbox.setPreferredSize(new java.awt.Dimension(255, 50)); - 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); }// //GEN-END:initComponents @Messages({ 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 0c97a98c2e..a03690feed 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=Currently waiting for all files uploaded to Cyber Triage to be processed before finalizing results. +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=Some lookup results were not processed due to exceeding limits. Please try again later. +MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_title=Some Lookup Results Not Processed 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,17 @@ 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. Cyber Triage processing 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 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 From 8917867778cd45a0c6d48f82342a201f3b490e56 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 15:33:29 -0400 Subject: [PATCH 14/32] some fixes --- .../df/cybertriage/autopsy/ctapi/CTApiDAO.java | 2 +- .../autopsy/ctapi/json/MetadataUploadRequest.java | 12 ++++++------ .../autopsy/malwarescan/MalwareScanIngestModule.java | 9 +++++---- 3 files changed, 12 insertions(+), 11 deletions(-) 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 b8883df4b2..c358feec61 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTApiDAO.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTApiDAO.java @@ -96,7 +96,7 @@ public class CTApiDAO { } public void uploadMeta(AuthenticatedRequestData authenticatedRequestData, MetadataUploadRequest metaRequest) throws CTCloudException { - httpClient.doPost(AUTH_TOKEN_REQUEST_PATH, getAuthParams(authenticatedRequestData), metaRequest, null); + httpClient.doPost(CTCLOUD_UPLOAD_FILE_METADATA_PATH, getAuthParams(authenticatedRequestData), metaRequest, null); } private static Map getAuthParams(AuthenticatedRequestData authenticatedRequestData) { 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 index 57d59c3faa..7d7e1e7f64 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/MetadataUploadRequest.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/MetadataUploadRequest.java @@ -38,10 +38,10 @@ public class MetadataUploadRequest { private String filePath; @JsonProperty("fileSize") - private long fileSizeBytes; + private Long fileSizeBytes; @JsonProperty("createdDate") - private long createdDate; + private Long createdDate; public String getFileUploadUrl() { return fileUploadUrl; @@ -88,20 +88,20 @@ public class MetadataUploadRequest { return this; } - public long getFileSizeBytes() { + public Long getFileSizeBytes() { return fileSizeBytes; } - public MetadataUploadRequest setFileSizeBytes(long fileSizeBytes) { + public MetadataUploadRequest setFileSizeBytes(Long fileSizeBytes) { this.fileSizeBytes = fileSizeBytes; return this; } - public long getCreatedDate() { + public Long getCreatedDate() { return createdDate; } - public MetadataUploadRequest setCreatedDate(long createdDate) { + public MetadataUploadRequest setCreatedDate(Long createdDate) { this.createdDate = createdDate; return this; } 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 dc54656a15..56bfde7356 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -462,7 +462,7 @@ public class MalwareScanIngestModule implements FileIngestModule { } if (performFileUpload) { - uploadFile(ingestJobState, correspondingObjIds.get(0)); + uploadFile(ingestJobState, sanitizedMd5, correspondingObjIds.get(0)); } ingestJobState.getUnidentifiedHashes().put(sanitizedMd5, correspondingObjIds); @@ -539,7 +539,7 @@ public class MalwareScanIngestModule implements FileIngestModule { * @throws CTCloudException * @throws TskCoreException */ - private boolean uploadFile(IngestJobState ingestJobState, long objId) throws CTCloudException, TskCoreException { + private boolean uploadFile(IngestJobState ingestJobState, String md5, long objId) throws CTCloudException, TskCoreException { if (!ingestJobState.isUploadUnknownFiles() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { return false; } @@ -567,11 +567,12 @@ public class MalwareScanIngestModule implements FileIngestModule { // upload metadata MetadataUploadRequest metaRequest = new MetadataUploadRequest() - .setCreatedDate(af.getCrtime()) + .setCreatedDate(af.getCrtime() == 0 ? null : af.getCrtime()) .setFilePath(af.getUniquePath()) .setFileSizeBytes(af.getSize()) .setFileUploadUrl(authTokenResponse.getFileUploadUrl()) - .setMd5(af.getMd5Hash()) + .setMd5(md5) + // these may be missing, but that's fine .setSha1(af.getSha1Hash()) .setSha256(af.getSha256Hash()); From 3e2bbc64219bffa3237c982bb43de22183c22d55 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 17:32:46 -0400 Subject: [PATCH 15/32] updates for sha1 hashing --- .../autopsy/ctapi/CTCloudHttpClient.java | 1 + .../malwarescan/MalwareScanIngestModule.java | 94 +++++++++++++++---- 2 files changed, 77 insertions(+), 18 deletions(-) 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 91a180e9bc..06e5fb7fcd 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java @@ -200,6 +200,7 @@ public class CTCloudHttpClient { public void doFileUploadPost(String urlPath, String fileName, InputStream fileIs) throws CTCloudException { try (CloseableHttpClient httpclient = createConnection(getProxySettings(), sslContext)) { + LOGGER.log(Level.INFO, "initiating http post request to ctcloud server " + urlPath); HttpPost post = new HttpPost(urlPath); configureRequestTimeout(post); 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 56bfde7356..5cbbbed372 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -27,10 +27,14 @@ 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.DigestInputStream; +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; @@ -133,6 +137,7 @@ public class MalwareScanIngestModule implements FileIngestModule { private final CTLicensePersistence ctSettingsPersistence = CTLicensePersistence.getInstance(); private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); + private final UsernameAnonymizer usernameAnonymizer = new UsernameAnonymizer(); private IngestJobState ingestJobState = null; @@ -258,31 +263,86 @@ public class MalwareScanIngestModule implements FileIngestModule { * @param af The abstract file. * @return The md5 hash (or null if could not be determined). */ - private static String getOrCalcHash(AbstractFile af) { - if (StringUtils.isNotBlank(af.getMd5Hash())) { - return af.getMd5Hash(); + 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 hashResults = HashUtility.calculateHashes(af, Collections.singletonList(HashType.MD5)); + List 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 @@ -305,7 +365,7 @@ public class MalwareScanIngestModule implements FileIngestModule { && 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)); } @@ -393,12 +453,11 @@ public class MalwareScanIngestModule implements FileIngestModule { * @param repResult The ct cloud results. * @throws org.sleuthkit.datamodel.Blackboard.BlackboardException * @throws TskCoreException - * @throws TskCoreException */ @Messages({ "MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_title=Some Lookup Results Not Processed", "MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_desc=Some lookup results were not processed due to exceeding limits. Please try again later.",}) - private void handleLookupResults(IngestJobState ingestJobState, Map> md5ToObjId, List repResult) throws Blackboard.BlackboardException, TskCoreException, TskCoreException, CTCloudException { + private void handleLookupResults(IngestJobState ingestJobState, Map> md5ToObjId, List repResult) throws Blackboard.BlackboardException, TskCoreException, TskCoreException, CTCloudException, NoSuchAlgorithmException, ReadContentInputStream.ReadContentInputStreamException { if (CollectionUtils.isEmpty(repResult)) { return; } @@ -445,7 +504,7 @@ public class MalwareScanIngestModule implements FileIngestModule { * @param performFileUpload True if the class of results warrants file * upload (i.e. NOT_FOUND) */ - private void handleNonFoundResults(IngestJobState ingestJobState, Map> md5ToObjId, List results, boolean performFileUpload) throws CTCloudException, TskCoreException { + private void handleNonFoundResults(IngestJobState ingestJobState, Map> md5ToObjId, List results, boolean performFileUpload) throws CTCloudException, TskCoreException, NoSuchAlgorithmException, ReadContentInputStream.ReadContentInputStreamException { if (CollectionUtils.isNotEmpty(results) && ingestJobState.isDoFileLookups() && ((performFileUpload && ingestJobState.isUploadUnknownFiles()) || (!performFileUpload && ingestJobState.isQueryForMissing()))) { @@ -539,7 +598,7 @@ public class MalwareScanIngestModule implements FileIngestModule { * @throws CTCloudException * @throws TskCoreException */ - private boolean uploadFile(IngestJobState ingestJobState, String md5, long objId) throws CTCloudException, TskCoreException { + private boolean uploadFile(IngestJobState ingestJobState, String md5, long objId) throws CTCloudException, TskCoreException, NoSuchAlgorithmException, ReadContentInputStream.ReadContentInputStreamException { if (!ingestJobState.isUploadUnknownFiles() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { return false; } @@ -568,13 +627,12 @@ public class MalwareScanIngestModule implements FileIngestModule { // upload metadata MetadataUploadRequest metaRequest = new MetadataUploadRequest() .setCreatedDate(af.getCrtime() == 0 ? null : af.getCrtime()) - .setFilePath(af.getUniquePath()) + .setFilePath(usernameAnonymizer.anonymousUsername(af.getUniquePath())) .setFileSizeBytes(af.getSize()) .setFileUploadUrl(authTokenResponse.getFileUploadUrl()) .setMd5(md5) - // these may be missing, but that's fine - .setSha1(af.getSha1Hash()) - .setSha256(af.getSha256Hash()); + .setSha1(getOrCalcSha1(af)) + .setSha256(getOrCalcSha256(af)); ctApiDAO.uploadMeta(new AuthenticatedRequestData(ingestJobState.getLicenseInfo().getDecryptedLicense(), authTokenResponse), metaRequest); return true; @@ -627,7 +685,7 @@ public class MalwareScanIngestModule implements FileIngestModule { createAnalysisResults(ingestJobState, found, remaining); // remove any found items from the list of items to long poll for - for (CTCloudBean foundItem : found) { + for (CTCloudBean foundItem : CollectionUtils.emptyIfNull(found)) { String normalizedMd5 = normalizedMd5(foundItem.getMd5HashValue()); remaining.remove(normalizedMd5); } @@ -768,8 +826,8 @@ public class MalwareScanIngestModule implements FileIngestModule { // flush any remaining items try { - longPollForNotFound(ingestJobState); batchProcessor.flushAndReset(); + longPollForNotFound(ingestJobState); } catch (InterruptedException ex) { notifyWarning( Bundle.MalwareScanIngestModule_SharedProcessing_flushTimeout_title(), From 2dd82a21ea6bc45a1c003ceed1d2bb8a484d0809 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 17:32:54 -0400 Subject: [PATCH 16/32] updates for sha1 hashing --- .../malwarescan/UsernameAnonymizer.java | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java new file mode 100644 index 0000000000..2b00ece4ab --- /dev/null +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java @@ -0,0 +1,155 @@ +/* + * Autopsy Forensic Browser + * + * Copyright 2023 Basis Technology Corp. + * Contact: carrier sleuthkit 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.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; +import org.sleuthkit.autopsy.coreutils.Logger; + +/** + * Utility class to anonymize username in paths also anonymizes hostname / ip + * from UNC paths + */ +public class UsernameAnonymizer { + + private static final Logger LOGGER = Logger.getLogger(UsernameAnonymizer.class.getName()); + + private final String USER_PATH_FORWARD_SLASH_REGEX = "(?"); + } + String anonymousString = matcher.replaceAll(replacement); + + 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) { + Pattern pattern = WINDOWS_VERSION < 6 ? Pattern.compile(USER_PATH_BACK_SLASH_REGEX_XP, Pattern.CASE_INSENSITIVE) : Pattern.compile(USER_PATH_BACK_SLASH_REGEX, Pattern.CASE_INSENSITIVE); + Matcher matcher = pattern.matcher(stringWithUsername.toLowerCase(Locale.ENGLISH)); + String replacement = ""; + while (matcher.find()) { + replacement = String.format("$1%s$4", ""); + } + String anonymousString = matcher.replaceAll(replacement); + + return anonymousString; + } + + private String anonymizeServerFromUNCPath(String inputString) { + + Set 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)) { + if (isLocalIP(serverName)) { + anonymousString = StringUtils.replace(anonymousString, "\\" + serverName + "\\", "\\\\"); + anonymousString = StringUtils.replace(anonymousString, "/" + serverName + "/", "//"); + } + } else { + anonymousString = StringUtils.replace(anonymousString, "\\" + serverName + "\\", "\\\\"); + anonymousString = StringUtils.replace(anonymousString, "/" + serverName + "/", "//"); + } + + } + + 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; + } + } + +} From e3353b29160eeea1ad7856d6e15c65529070ca14 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 18:32:32 -0400 Subject: [PATCH 17/32] fixes for cancellation --- .../malwarescan/MalwareScanIngestModule.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 5cbbbed372..f528feb653 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -639,10 +639,8 @@ public class MalwareScanIngestModule implements FileIngestModule { } /** - * - * @param ingestJobState - * @param md5objIdMapping - * @return + * 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 @@ -696,7 +694,13 @@ public class MalwareScanIngestModule implements FileIngestModule { } // exponential backoff before trying again - Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS * ((long) Math.pow(2, retry))); + long waitMultiplier = ((long) Math.pow(2, retry)); + for (int i = 0; i < waitMultiplier; i++) { + if (ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { + return; + } + Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS); + } } notifyWarning( From dc9c212db521e8451785c022b44b0dc171686bbc Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 19:41:29 -0400 Subject: [PATCH 18/32] fixes --- .../malwarescan/Bundle.properties-MERGED | 5 +++ .../malwarescan/MalwareScanIngestModule.java | 35 +++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) 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 a03690feed..9aecc568df 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 @@ -33,6 +33,11 @@ MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc=There are no mor 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 f528feb653..9b69226c58 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -27,7 +27,6 @@ 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.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.MessageFormat; @@ -559,6 +558,18 @@ public class MalwareScanIngestModule implements FileIngestModule { } 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.getHashLookupCount()); + if (remainingUploads <= 0) { + ingestJobState.disableUploadUnknownFiles(); + notifyWarning( + Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_title(), + Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_desc(), + null); + } + } // using auth token, get results return ctApiDAO.getReputationResults( @@ -598,13 +609,27 @@ public class MalwareScanIngestModule implements FileIngestModule { * @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 || !isUploadable(af)) { + if (af == null) { + return false; + } + + if (!isUploadable(af)) { + notifyWarning( + Bundle.MalwareScanIngestModule_uploadFile_notUploadable_title(), + Bundle.MalwareScanIngestModule_uploadFile_notUploadable_desc(objId), + null); return false; } @@ -615,6 +640,11 @@ public class MalwareScanIngestModule implements FileIngestModule { } 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; @@ -640,6 +670,7 @@ public class MalwareScanIngestModule implements FileIngestModule { /** * Does long polling for any pending results. + * * @param ingestJobState The state of the ingest job. * @throws InterruptedException * @throws CTCloudException From ce1c57dc79f55157b474b9e73495c56688aec229 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 27 Jul 2023 20:22:22 -0400 Subject: [PATCH 19/32] some updates --- .../malwarescan/MalwareScanIngestModule.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 9b69226c58..bb7df071b2 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -558,16 +558,16 @@ public class MalwareScanIngestModule implements FileIngestModule { } 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.getHashLookupCount()); + long remainingUploads = remaining(authTokenResponse.getFileUploadLimit(), authTokenResponse.getFileUploadCount()); if (remainingUploads <= 0) { ingestJobState.disableUploadUnknownFiles(); notifyWarning( - Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_title(), - Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_desc(), - null); + Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_title(), + Bundle.MalwareScanIngestModule_uploadFile_noRemainingFileUploads_desc(), + null); } } @@ -683,7 +683,10 @@ public class MalwareScanIngestModule implements FileIngestModule { "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())) { + if (!ingestJobState.isDoFileLookups() + || !ingestJobState.isQueryForMissing() + || MapUtils.isEmpty(ingestJobState.getUnidentifiedHashes()) + || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { return; } From ffb1927a281c28a2b7bdaed6bdbf39ca33dff65f Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Tue, 1 Aug 2023 12:10:27 -0400 Subject: [PATCH 20/32] message updates --- .../autopsy/ctoptions/ctcloud/Bundle.properties | 2 +- .../autopsy/ctoptions/ctcloud/Bundle.properties-MERGED | 2 +- .../autopsy/malwarescan/Bundle.properties-MERGED | 8 ++++---- .../autopsy/malwarescan/MalwareScanIngestModule.java | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) 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 979010bb23..d0395d6a1a 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,5 +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 executable if executable is unknown +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 376ddfa415..b05bb1cfe4 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 @@ -56,5 +56,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 executable if executable is unknown +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/malwarescan/Bundle.properties-MERGED b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/Bundle.properties-MERGED index 9aecc568df..4380a9607c 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,4 +1,4 @@ -MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_desc=Currently waiting for all files uploaded to Cyber Triage to be processed before finalizing results. +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 @@ -10,8 +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=Some lookup results were not processed due to exceeding limits. Please try again later. -MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_title=Some Lookup Results Not Processed +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 @@ -29,7 +29,7 @@ MalwareScanIngestModule_ShareProcessing_lowUploadsLimitWarning_desc=This license 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_noLookupsRemaining_desc=There are no more remaining hash lookups for this license at this time. Cyber Triage processing will be disabled. +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 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 bb7df071b2..f15236cd1d 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -145,7 +145,7 @@ public class MalwareScanIngestModule implements FileIngestModule { "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_noLookupsRemaining_title=No remaining lookups", - "MalwareScanIngestModule_ShareProcessing_noLookupsRemaining_desc=There are no more remaining hash lookups for this license at this time. Cyber Triage processing will be disabled.", + "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.", @@ -454,8 +454,8 @@ public class MalwareScanIngestModule implements FileIngestModule { * @throws TskCoreException */ @Messages({ - "MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_title=Some Lookup Results Not Processed", - "MalwareScanIngestModule_SharedProcessing_exhaustedResultsHashLookups_desc=Some lookup results were not processed due to exceeding limits. Please try again later.",}) + "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> md5ToObjId, List repResult) throws Blackboard.BlackboardException, TskCoreException, TskCoreException, CTCloudException, NoSuchAlgorithmException, ReadContentInputStream.ReadContentInputStreamException { if (CollectionUtils.isEmpty(repResult)) { return; @@ -679,7 +679,7 @@ public class MalwareScanIngestModule implements FileIngestModule { */ @Messages({ "MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_title=Waiting for File Upload Results", - "MalwareScanIngestModule_longPollForNotFound_fileLookupPolling_desc=Currently waiting for all files uploaded to Cyber Triage to be processed before finalizing 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 { From 5368f84853e92e99b3df1a569af6eff6d29714af Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Tue, 1 Aug 2023 21:04:30 -0400 Subject: [PATCH 21/32] change for package scope --- .../autopsy/ctapi/CTCloudHttpClient.java | 2 +- .../cybertriage/autopsy/ctapi/Constants.java | 2 +- .../autopsy/ctapi/ProxySettings.java | 2 +- .../ctoptions/ctcloud/CTLicenseDialog.java | 2 +- .../ctcloud/CTLicensePersistence.java | 1 - .../autopsy/ctoptions/ctcloud/EULADialog.java | 2 +- .../autopsy/malwarescan/BatchProcessor.java | 6 +- .../autopsy/malwarescan/FileUpload.java | 93 ------------------- .../malwarescan/MalwareScanIngestModule.java | 2 +- .../malwarescan/UsernameAnonymizer.java | 2 +- 10 files changed, 8 insertions(+), 106 deletions(-) delete mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java 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 06e5fb7fcd..0374fd2b2b 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java @@ -67,7 +67,7 @@ import org.sleuthkit.autopsy.coreutils.Version; /** * Makes the http requests to CT cloud. */ -public class CTCloudHttpClient { +class CTCloudHttpClient { private static final CTCloudHttpClient instance = new CTCloudHttpClient(); private static final Logger LOGGER = Logger.getLogger(CTCloudHttpClient.class.getName()); 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 9587b3cd44..8290d6621d 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 @@ import java.net.URI; /** * 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 index f710a6ab1b..cbd538d01b 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/ProxySettings.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/ProxySettings.java @@ -32,7 +32,7 @@ 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 { +class ProxySettings { public static final String PROXY_HTTP_HOST = "proxyHttpHost"; // NOI18N public static final String PROXY_HTTP_PORT = "proxyHttpPort"; // NOI18N 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 608ea63040..3bff998901 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 @@ import org.openide.util.NbBundle.Messages; /** * 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 e95d9e7279..717c4f4321 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.core.JsonProcessingException; 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; 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 31056e1f2a..0279cafb9b 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 @@ import org.sleuthkit.autopsy.coreutils.Logger; /** * 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/malwarescan/BatchProcessor.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/BatchProcessor.java index eab025a641..a65d319dc2 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 @@ package com.basistech.df.cybertriage.autopsy.malwarescan; 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 @@ import java.util.function.Consumer; * blocks (and subsequently add and flush operations) until previous batch * finishes. */ -public class BatchProcessor { +class BatchProcessor { private ExecutorService processingExecutorService = Executors.newSingleThreadExecutor(); diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java deleted file mode 100644 index 4803793514..0000000000 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/FileUpload.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Autopsy Forensic Browser - * - * Copyright 2023 Basis Technology Corp. - * Contact: carrier sleuthkit 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.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.DecryptedLicenseResponse; -import com.basistech.df.cybertriage.autopsy.ctapi.json.MalwareResultBean.Status; -import com.basistech.df.cybertriage.autopsy.ctapi.json.MetadataUploadRequest; -import org.apache.commons.lang3.StringUtils; -import org.sleuthkit.datamodel.AbstractFile; -import org.sleuthkit.datamodel.ReadContentInputStream; -import org.sleuthkit.datamodel.SleuthkitCase; -import org.sleuthkit.datamodel.TskCoreException; - -/** - * Handles uploading of files that are unknown. - */ -public class FileUpload { - private static final long MIN_UPLOAD_SIZE = 1; - private static final long MAX_UPLOAD_SIZE = 1_000_000_000; - - private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); - - private boolean isUnknown(CTCloudBean cloudBean) { - return cloudBean != null - && cloudBean.getMalwareResult() != null - && cloudBean.getMalwareResult().getStatus() == Status.NOT_FOUND; - } - - private boolean isUploadable(AbstractFile af) { - long size = af.getSize(); - return size >= MIN_UPLOAD_SIZE && size <= MAX_UPLOAD_SIZE; - } - - private boolean upload(SleuthkitCase skCase, DecryptedLicenseResponse decrypted, CTCloudBean cloudBean, long objId) throws CTCloudException, TskCoreException { - if (!isUnknown(cloudBean)) { - return false; - } - - AbstractFile af = skCase.getAbstractFileById(objId); - if (af == null) { - return false; - } - - if (!isUploadable(af)) { - return false; - } - - // get auth token / file upload url - AuthTokenResponse authTokenResponse = ctApiDAO.getAuthToken(decrypted, true); - if (StringUtils.isBlank(authTokenResponse.getFileUploadUrl())) { - throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR); - } - - // upload bytes - ReadContentInputStream fileInputStream = new ReadContentInputStream(af); - ctApiDAO.uploadFile(authTokenResponse.getFileUploadUrl(), af.getName(), fileInputStream); - - // upload metadata - MetadataUploadRequest metaRequest = new MetadataUploadRequest() - .setCreatedDate(af.getCrtime()) - .setFilePath(af.getUniquePath()) - .setFileSizeBytes(af.getSize()) - .setFileUploadUrl(authTokenResponse.getFileUploadUrl()) - .setMd5(af.getMd5Hash()) - .setSha1(af.getSha1Hash()) - .setSha256(af.getSha256Hash()); - - ctApiDAO.uploadMeta(new AuthenticatedRequestData(decrypted, authTokenResponse), metaRequest); - return true; - } - -} 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 f15236cd1d..2d73effa5c 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -69,7 +69,7 @@ import org.sleuthkit.datamodel.TskData; /** * 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(); diff --git a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java index 2b00ece4ab..75261ae43f 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java @@ -33,7 +33,7 @@ import org.sleuthkit.autopsy.coreutils.Logger; * Utility class to anonymize username in paths also anonymizes hostname / ip * from UNC paths */ -public class UsernameAnonymizer { +class UsernameAnonymizer { private static final Logger LOGGER = Logger.getLogger(UsernameAnonymizer.class.getName()); From 7ec44a2fb290ff6315ef9611253dafc50b1716a8 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 3 Aug 2023 15:37:14 -0400 Subject: [PATCH 22/32] trust store changes --- .../autopsy/ctapi/CTCloudHttpClient.java | 98 ++++++++++++++++--- 1 file changed, 85 insertions(+), 13 deletions(-) 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 0374fd2b2b..622595083e 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java @@ -28,6 +28,12 @@ import java.net.PasswordAuthentication; 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; @@ -37,7 +43,12 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.logging.Level; +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.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpEntity; @@ -62,6 +73,7 @@ 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.ssl.SSLInitializationException; import org.sleuthkit.autopsy.coreutils.Version; /** @@ -69,7 +81,8 @@ import org.sleuthkit.autopsy.coreutils.Version; */ class CTCloudHttpClient { - private static final CTCloudHttpClient instance = new CTCloudHttpClient(); + private static final String KS_PASSWORD = "changeit"; // system default java password + 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; @@ -84,6 +97,8 @@ class CTCloudHttpClient { private static final int CONNECTION_TIMEOUT_MS = 58 * 1000; // milli sec + private static final CTCloudHttpClient instance = new CTCloudHttpClient(); + public static CTCloudHttpClient getInstance() { return instance; } @@ -94,7 +109,7 @@ class CTCloudHttpClient { private CTCloudHttpClient() { // leave as null for now unless we want to customize this at a later date - this.sslContext = null; + this.sslContext = createSSLContext(); } private ProxySettingArgs getProxySettings() { @@ -125,7 +140,7 @@ class CTCloudHttpClient { null ); } - + public O doPost(String urlPath, Object jsonBody, Class classType) throws CTCloudException { return doPost(urlPath, Collections.emptyMap(), jsonBody, classType); } @@ -137,7 +152,7 @@ class CTCloudHttpClient { 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 e : urlReqParams.entrySet()) { String key = e.getKey(); @@ -151,7 +166,6 @@ class CTCloudHttpClient { URI postURI = builder.build(); HttpPost postRequest = new HttpPost(postURI); - configureRequestTimeout(postRequest); postRequest.setHeader("Content-type", "application/json"); @@ -189,6 +203,9 @@ class CTCloudHttpClient { } catch (IOException ex) { LOGGER.log(Level.WARNING, "IO Exception raised when connecting to CT Cloud using " + url, 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 " + url, ex); + throw new CTCloudException(CTCloudException.ErrorCode.NETWORK_ERROR, ex); } catch (URISyntaxException ex) { LOGGER.log(Level.WARNING, "Wrong URL syntax for CT Cloud " + url, ex); throw new CTCloudException(CTCloudException.ErrorCode.UNKNOWN, ex); @@ -196,16 +213,16 @@ class CTCloudHttpClient { return null; } - + public void doFileUploadPost(String urlPath, String fileName, InputStream fileIs) throws CTCloudException { - + try (CloseableHttpClient httpclient = createConnection(getProxySettings(), sslContext)) { LOGGER.log(Level.INFO, "initiating http post request to ctcloud server " + urlPath); HttpPost post = new HttpPost(urlPath); configureRequestTimeout(post); - + post.addHeader("Connection", "keep-alive"); - + MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.addBinaryBody( "file", @@ -213,10 +230,10 @@ class CTCloudHttpClient { 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) { @@ -226,6 +243,9 @@ class CTCloudHttpClient { 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); @@ -295,19 +315,71 @@ class CTCloudHttpClient { /** * 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. */ - private static CloseableHttpClient createConnection(ProxySettingArgs proxySettings, SSLContext sslContext) { + private static CloseableHttpClient createConnection(ProxySettingArgs proxySettings, SSLContext sslContext) throws SSLInitializationException { HttpClientBuilder builder = getHttpClientBuilder(proxySettings); - if (sslContext != null) { builder.setSSLContext(sslContext); } + return builder.build(); } + /** + * Create an SSLContext object using our in-memory keystore. + * + * @return + */ + private static SSLContext createSSLContext() { + LOGGER.log(Level.INFO, "Creating custom SSL context"); + try { + + // 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; + } + } + + // 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]; + } + + } + + // 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]; + } + } + private static HttpClientBuilder getHttpClientBuilder(ProxySettingArgs proxySettings) { if (proxySettings.isSystemOrManualProxy()) { From 8ed27b935d8be9d0285b394833dfae00291b5ebd Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Fri, 4 Aug 2023 14:35:30 -0400 Subject: [PATCH 23/32] updates for proxy configuration --- .../autopsy/ctapi/CTCloudHttpClient.java | 85 ++-- .../autopsy/ctapi/ProxySettings.java | 446 ------------------ .../ctcloud/CTMalwareScannerOptionsPanel.java | 5 +- 3 files changed, 59 insertions(+), 477 deletions(-) delete mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/ctapi/ProxySettings.java 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 622595083e..003a9e4347 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java @@ -74,6 +74,8 @@ 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.ssl.SSLInitializationException; +import org.netbeans.core.ProxySettings; +import org.openide.util.NetworkSettings; import org.sleuthkit.autopsy.coreutils.Version; /** @@ -81,8 +83,6 @@ import org.sleuthkit.autopsy.coreutils.Version; */ class CTCloudHttpClient { - private static final String KS_PASSWORD = "changeit"; // system default java password - 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; @@ -112,7 +112,7 @@ class CTCloudHttpClient { this.sslContext = createSSLContext(); } - private ProxySettingArgs getProxySettings() { + private ProxySettingArgs getProxySettings(URI uri) { if (StringUtils.isBlank(hostName)) { try { hostName = InetAddress.getLocalHost().getCanonicalHostName(); @@ -121,24 +121,49 @@ class CTCloudHttpClient { } } + String proxyPortStr = uri != null ? NetworkSettings.getProxyPort(uri) : ProxySettings.getHttpPort(); int proxyPort = 0; - if (StringUtils.isNotBlank(ProxySettings.getHttpPort())) { + if (StringUtils.isNotBlank(proxyPortStr)) { try { - proxyPort = Integer.parseInt(ProxySettings.getHttpsPort()); + proxyPort = Integer.parseInt(proxyPortStr); } catch (NumberFormatException ex) { LOGGER.log(Level.WARNING, "Unable to convert port to integer"); } } - return new ProxySettingArgs( + String proxyHost = uri != null ? NetworkSettings.getProxyHost(uri) : ProxySettings.getHttpHost(); + + ProxySettingArgs proxySettings = new ProxySettingArgs( ProxySettings.getProxyType() != ProxySettings.DIRECT_CONNECTION, hostName, - ProxySettings.getHttpsHost(), + proxyHost, proxyPort, ProxySettings.getAuthenticationUsername(), ProxySettings.getAuthenticationPassword(), null ); + + // TODO comment out later + LOGGER.log(Level.INFO, MessageFormat.format("Proxy settings to be used with {0} are {1}.", uri, proxySettings)); + + return proxySettings; + } + + private static URI getUri(String host, String path, Map urlReqParams) throws URISyntaxException { + String url = host + path; + URIBuilder builder = new URIBuilder(url); + + if (!MapUtils.isEmpty(urlReqParams)) { + for (Entry e : urlReqParams.entrySet()) { + String key = e.getKey(); + String value = e.getValue(); + if (StringUtils.isNotBlank(key) || StringUtils.isNotBlank(value)) { + builder.addParameter(key, value); + } + } + } + + return builder.build(); } public O doPost(String urlPath, Object jsonBody, Class classType) throws CTCloudException { @@ -146,24 +171,13 @@ class CTCloudHttpClient { } public O doPost(String urlPath, Map urlReqParams, Object jsonBody, Class classType) throws CTCloudException { - String url = HOST_URL + urlPath; + + 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); + try (CloseableHttpClient httpclient = createConnection(getProxySettings(postURI), sslContext)) { - if (!MapUtils.isEmpty(urlReqParams)) { - for (Entry e : urlReqParams.entrySet()) { - String key = e.getKey(); - String value = e.getValue(); - if (StringUtils.isNotBlank(key) || StringUtils.isNotBlank(value)) { - builder.addParameter(key, value); - } - } - } - - URI postURI = builder.build(); HttpPost postRequest = new HttpPost(postURI); configureRequestTimeout(postRequest); @@ -201,24 +215,31 @@ class CTCloudHttpClient { } } } 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 " + url, 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 urlPath, String fileName, InputStream fileIs) throws CTCloudException { + 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(getProxySettings(), sslContext)) { - LOGGER.log(Level.INFO, "initiating http post request to ctcloud server " + urlPath); - HttpPost post = new HttpPost(urlPath); + try (CloseableHttpClient httpclient = createConnection(getProxySettings(postUri), 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"); @@ -508,5 +529,11 @@ class CTCloudHttpClient { public String getAuthScheme() { return authScheme; } + + @Override + public String toString() { + return "ProxySettingArgs{" + "systemOrManualProxy=" + systemOrManualProxy + ", hostName=" + hostName + ", proxyHostname=" + proxyHostname + ", proxyPort=" + proxyPort + ", proxyUserId=" + proxyUserId + ", proxyPassword set=" + (proxyPassword != null && proxyPassword.length > 0) + ", authScheme=" + authScheme + '}'; + } + } } 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 cbd538d01b..0000000000 --- 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 sleuthkit 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 - */ -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 set = new HashSet (); - 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 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 o.n.core and core.network. - * An implementation of this class brings a facility to reload Network Proxy Settings - * from underlying OS. - * The module core.network provides a implementation which may be accessible - * via Lookup.getDefault(). 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/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.java b/Core/src/com/basistech/df/cybertriage/autopsy/ctoptions/ctcloud/CTMalwareScannerOptionsPanel.java index 5dd3a1db66..99d3ea2710 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 @@ -33,6 +33,7 @@ import java.io.IOException; 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; @@ -552,7 +553,7 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { 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); @@ -608,7 +609,7 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { 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) { From 5f35af2247411aa27338a15cdd6c49bcd52a906d Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Fri, 4 Aug 2023 20:54:24 -0400 Subject: [PATCH 24/32] use Nb classes for auth and proxy selection --- .../autopsy/ctapi/CTCloudHttpClient.java | 281 ++++++------------ 1 file changed, 86 insertions(+), 195 deletions(-) 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 003a9e4347..039d1d9e83 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java @@ -22,12 +22,11 @@ import com.basistech.df.cybertriage.autopsy.ctapi.util.ObjectMapperUtil; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; -import java.net.Authenticator; -import java.net.InetAddress; -import java.net.PasswordAuthentication; +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; @@ -35,14 +34,13 @@ 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; @@ -50,14 +48,10 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; 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; @@ -71,29 +65,27 @@ 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.NetworkSettings; +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. */ class 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 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 @@ -105,48 +97,12 @@ class CTCloudHttpClient { 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 = createSSLContext(); - } - - private ProxySettingArgs getProxySettings(URI uri) { - if (StringUtils.isBlank(hostName)) { - try { - hostName = InetAddress.getLocalHost().getCanonicalHostName(); - } catch (UnknownHostException ex) { - LOGGER.log(Level.WARNING, "An error occurred while fetching the hostname", ex); - } - } - - String proxyPortStr = uri != null ? NetworkSettings.getProxyPort(uri) : ProxySettings.getHttpPort(); - int proxyPort = 0; - if (StringUtils.isNotBlank(proxyPortStr)) { - try { - proxyPort = Integer.parseInt(proxyPortStr); - } catch (NumberFormatException ex) { - LOGGER.log(Level.WARNING, "Unable to convert port to integer"); - } - } - - String proxyHost = uri != null ? NetworkSettings.getProxyHost(uri) : ProxySettings.getHttpHost(); - - ProxySettingArgs proxySettings = new ProxySettingArgs( - ProxySettings.getProxyType() != ProxySettings.DIRECT_CONNECTION, - hostName, - proxyHost, - proxyPort, - ProxySettings.getAuthenticationUsername(), - ProxySettings.getAuthenticationPassword(), - null - ); - - // TODO comment out later - LOGGER.log(Level.INFO, MessageFormat.format("Proxy settings to be used with {0} are {1}.", uri, proxySettings)); - - return proxySettings; + this.proxySelector = getProxySelector(); } private static URI getUri(String host, String path, Map urlReqParams) throws URISyntaxException { @@ -171,12 +127,12 @@ class CTCloudHttpClient { } public O doPost(String urlPath, Map urlReqParams, Object jsonBody, Class classType) throws CTCloudException { - + 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(postURI), sslContext)) { + try (CloseableHttpClient httpclient = createConnection(proxySelector, sslContext)) { HttpPost postRequest = new HttpPost(postURI); @@ -237,7 +193,7 @@ class CTCloudHttpClient { throw new CTCloudException(CTCloudException.ErrorCode.UNKNOWN, ex); } - try (CloseableHttpClient httpclient = createConnection(getProxySettings(postUri), sslContext)) { + 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); @@ -335,19 +291,30 @@ class CTCloudHttpClient { } /** - * Creates a connection to CT Cloud with the given arguments. + * Get ProxySelector present (favoring NbProxySelector if present). * - * @param proxySettings The network proxy settings. - * @param sslContext The ssl context or null. - * @return The connection to CT Cloud. + * @return The found ProxySelector or null. */ - private static CloseableHttpClient createConnection(ProxySettingArgs proxySettings, SSLContext sslContext) throws SSLInitializationException { - HttpClientBuilder builder = getHttpClientBuilder(proxySettings); - if (sslContext != null) { - builder.setSSLContext(sslContext); - } - - return builder.build(); + private static ProxySelector getProxySelector() { + Collection 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); } /** @@ -371,6 +338,7 @@ class CTCloudHttpClient { } } + // 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()); @@ -385,6 +353,7 @@ class CTCloudHttpClient { } + // 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 { @@ -401,138 +370,60 @@ class CTCloudHttpClient { } } - 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; - - if (Objects.nonNull(proxySettings.getProxyHostname()) && proxySettings.getProxyPort() > 0) { - proxyHost = new HttpHost(proxySettings.getProxyHostname(), proxySettings.getProxyPort()); - - 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(); - } - - 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(); - } - } - /** - * 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; + + if (ProxySettings.getProxyType() != ProxySettings.DIRECT_CONNECTION + && StringUtils.isBlank(ProxySettings.getAuthenticationUsername()) + && ArrayUtils.isEmpty(ProxySettings.getAuthenticationPassword()) + && WinHttpClients.isWinAuthAvailable()) { + + 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"); } - return proxyCredsProvider; + if (sslContext != null) { + builder.setSSLContext(sslContext); + } + + if (proxySelector != null) { + builder.setRoutePlanner(new SystemDefaultRoutePlanner(proxySelector)); + } + + return builder.build(); } - private static class ProxySettingArgs { + private static class LoggingProxySelector extends ProxySelector { - 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; + private final ProxySelector delegate; - 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; - } - - boolean isSystemOrManualProxy() { - return systemOrManualProxy; - } - - String getHostName() { - return hostName; - } - - String getProxyHostname() { - return proxyHostname; - } - - int getProxyPort() { - return proxyPort; - } - - String getProxyUserId() { - return proxyUserId; - } - - char[] getProxyPassword() { - return proxyPassword; - } - - public String getAuthScheme() { - return authScheme; + public LoggingProxySelector(ProxySelector delegate) { + this.delegate = delegate; } @Override - public String toString() { - return "ProxySettingArgs{" + "systemOrManualProxy=" + systemOrManualProxy + ", hostName=" + hostName + ", proxyHostname=" + proxyHostname + ", proxyPort=" + proxyPort + ", proxyUserId=" + proxyUserId + ", proxyPassword set=" + (proxyPassword != null && proxyPassword.length > 0) + ", authScheme=" + authScheme + '}'; + public List select(URI uri) { + List selectedProxies = delegate.select(uri); + LOGGER.log(Level.FINE, MessageFormat.format("Proxy selected for {0} are {1}", uri, selectedProxies)); + return selectedProxies; + } + + @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); } } From 35efa5a178ad8e5eccce8df54563f24d626ba349 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Sat, 5 Aug 2023 08:52:49 -0400 Subject: [PATCH 25/32] change log level --- .../df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 039d1d9e83..0a280c87e8 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/ctapi/CTCloudHttpClient.java @@ -416,7 +416,7 @@ class CTCloudHttpClient { @Override public List select(URI uri) { List selectedProxies = delegate.select(uri); - LOGGER.log(Level.FINE, MessageFormat.format("Proxy selected for {0} are {1}", uri, selectedProxies)); + LOGGER.log(Level.INFO, MessageFormat.format("Proxy selected for {0} are {1}", uri, selectedProxies)); return selectedProxies; } From 5aded98cec022611558af88662b5450079380526 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 7 Aug 2023 10:45:33 -0400 Subject: [PATCH 26/32] dont check for file uploads if out of hash lookups --- .../autopsy/malwarescan/MalwareScanIngestModule.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 2d73effa5c..1d5871af3b 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -730,9 +730,10 @@ class MalwareScanIngestModule implements FileIngestModule { // exponential backoff before trying again long waitMultiplier = ((long) Math.pow(2, retry)); for (int i = 0; i < waitMultiplier; i++) { - if (ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { + if (!ingestJobState.isDoFileLookups() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { return; } + Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS); } } From c35357938d1c5cb1596b5f7f3e09147c206f663f Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 7 Aug 2023 16:29:08 -0400 Subject: [PATCH 27/32] update for limit type --- .../ctapi/json/DecryptedLicenseResponse.java | 6 +- .../ctcloud/Bundle.properties-MERGED | 10 ++- .../ctcloud/CTMalwareScannerOptionsPanel.java | 72 ++++++++++++++++--- 3 files changed, 72 insertions(+), 16 deletions(-) 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 c6f91721ef..d939d6ed75 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 class 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 class DecryptedLicenseResponse { return product; } - public String getLimitType() { + public LicenseLimitType getLimitType() { return limitType; } 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 b05bb1cfe4..18e60839f3 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 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 99d3ea2710..2a09c1ed55 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.CTApiDAO; 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; @@ -130,7 +131,7 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { if (licenseInfo != null) { loadMalwareScansInfo(licenseInfo); } - + MalwareIngestSettings ingestSettings = ctPersistence.loadMalwareIngestSettings(); setIngestSettings(ingestSettings); } @@ -138,12 +139,12 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { 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(); @@ -439,9 +440,11 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { "# {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", @@ -477,7 +480,7 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { 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); @@ -485,15 +488,62 @@ public class CTMalwareScannerOptionsPanel extends CTOptionsSubPanel { 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 ""; } } From 170a8583408090615c0beca0209193a064a3830a Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Mon, 7 Aug 2023 16:29:16 -0400 Subject: [PATCH 28/32] update for limit type --- .../autopsy/ctapi/json/LicenseLimitType.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/ctapi/json/LicenseLimitType.java 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 0000000000..7d185f86ae --- /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 sleuthkit 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; +} From 89950593b4660cb86c5f44887b7f4fa254c49123 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Tue, 8 Aug 2023 20:03:49 -0400 Subject: [PATCH 29/32] doxygen update --- docs/doxygen-user/ct_malware_scanner.dox | 28 ++++++++++++++++++ .../images/ct_malware_license_agreement.png | Bin 0 -> 56663 bytes .../ct_malware_scanner_options_panel.png | Bin 0 -> 35019 bytes docs/doxygen-user/images/ct_upload_file.png | Bin 0 -> 52102 bytes docs/doxygen-user/main.dox | 1 + 5 files changed, 29 insertions(+) create mode 100644 docs/doxygen-user/ct_malware_scanner.dox create mode 100644 docs/doxygen-user/images/ct_malware_license_agreement.png create mode 100644 docs/doxygen-user/images/ct_malware_scanner_options_panel.png create mode 100644 docs/doxygen-user/images/ct_upload_file.png diff --git a/docs/doxygen-user/ct_malware_scanner.dox b/docs/doxygen-user/ct_malware_scanner.dox new file mode 100644 index 0000000000..4d49394156 --- /dev/null +++ b/docs/doxygen-user/ct_malware_scanner.dox @@ -0,0 +1,28 @@ +/*! \page ct_malware_scanner Cyber Triage Malware Scanner + +[TOC] + + +\section ct_malware_scanner_overview Overview + +The Malware Scanner Ingest Module uses Cyber Triage Cloud to identify if any executables in a data source are malware based on the executable's md5 hash. + +\section ct_malware_scanner_config Configuration + +Before using the Malware Scanner Ingest Module, you must register a Cyber Triage Cloud License. A license number can be added by selecting the 'Options' menu item from the 'Tools' menu, going to the 'Cyber Triage' tab, and then clicking 'Add License'. + +\image html ct_malware_scanner_options_panel.png + +The user will then be presented with a dialog to enter your license number. Enter your license number and then press 'OK'. If your license number is validated, you will be presented with the Cyber Triage End User License Agreement. The window may take a moment to load. + +\image html ct_malware_license_agreement.png + +Read through the license agreement, and press 'Accept'. At that point, your options panel should load with information pertaining to remaining lookups. + +\image html ct_upload_file.png + +\section ct_upload_executable Uploading Executable + +In the screenshot above, there is the option “Upload executable if executable is unknown.” In the event that an executable has not previously been seen by Cyber Triage Cloud, this option provides the ability to upload the executable for scanning. This option may cause increased processing time in order to upload the file and wait for scanning to complete. + +*/ 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 GIT binary patch literal 56663 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⋙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 Date: Wed, 9 Aug 2023 12:17:36 -0400 Subject: [PATCH 30/32] error message update --- .../df/cybertriage/autopsy/ctapi/CTCloudException.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 52d586ee53..95605c4bce 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 class CTCloudException extends Exception{ 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 { From f1583a03534467d30b3292828cae0a0196996b10 Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Wed, 9 Aug 2023 14:57:55 -0400 Subject: [PATCH 31/32] updated text in ct options panel disclaimer --- .../autopsy/ctoptions/Bundle.properties | 4 +- .../ctoptions/Bundle.properties-MERGED | 4 +- .../ctoptions/LicenseDisclaimerPanel.form | 157 +++++++++++++----- .../ctoptions/LicenseDisclaimerPanel.java | 129 ++++++++++---- 4 files changed, 210 insertions(+), 84 deletions(-) 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 46f62fdf7a..840725f5d7 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=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. +LicenseDisclaimerPanel.disclaimer.text=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. LicenseDisclaimerPanel.purchaseFromLabel.text=You can purchase a license from -LicenseDisclaimerPanel.link.text=https://cybertriage.com/autopsy-checkout 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 46f62fdf7a..840725f5d7 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=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. +LicenseDisclaimerPanel.disclaimer.text=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. LicenseDisclaimerPanel.purchaseFromLabel.text=You can purchase a license from -LicenseDisclaimerPanel.link.text=https://cybertriage.com/autopsy-checkout 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 83e3c8440a..a88404c592 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 @@ + - + - + - + @@ -29,7 +30,7 @@ - + @@ -47,45 +48,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -96,7 +59,7 @@ - + @@ -113,5 +76,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 4299f02d62..328a252467 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 "" + url + ""; + } /** * Creates new form LicenseDisclaimerPanel @@ -52,14 +57,18 @@ public class LicenseDisclaimerPanel extends javax.swing.JPanel { 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 @@ public class LicenseDisclaimerPanel extends javax.swing.JPanel { 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 class LicenseDisclaimerPanel extends javax.swing.JPanel { 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); }// //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 } From 1b78b4964bc2e17d334af4555bb810d765f7905f Mon Sep 17 00:00:00 2001 From: Greg DiCristofaro Date: Thu, 10 Aug 2023 14:03:17 -0400 Subject: [PATCH 32/32] updates for path normalizer --- .../malwarescan/MalwareScanIngestModule.java | 16 +- .../autopsy/malwarescan/PathNormalizer.java | 201 ++++++++++++++++++ .../malwarescan/UsernameAnonymizer.java | 155 -------------- 3 files changed, 213 insertions(+), 159 deletions(-) create mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/PathNormalizer.java delete mode 100644 Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java 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 1d5871af3b..c52e72a350 100644 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java +++ b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/MalwareScanIngestModule.java @@ -136,7 +136,6 @@ class MalwareScanIngestModule implements FileIngestModule { private final CTLicensePersistence ctSettingsPersistence = CTLicensePersistence.getInstance(); private final CTApiDAO ctApiDAO = CTApiDAO.getInstance(); - private final UsernameAnonymizer usernameAnonymizer = new UsernameAnonymizer(); private IngestJobState ingestJobState = null; @@ -235,6 +234,7 @@ class MalwareScanIngestModule implements FileIngestModule { return new IngestJobState( context, tskCase, + new PathNormalizer(tskCase), new FileTypeDetector(), licenseInfoOpt.get(), malwareType, @@ -657,7 +657,7 @@ class MalwareScanIngestModule implements FileIngestModule { // upload metadata MetadataUploadRequest metaRequest = new MetadataUploadRequest() .setCreatedDate(af.getCrtime() == 0 ? null : af.getCrtime()) - .setFilePath(usernameAnonymizer.anonymousUsername(af.getUniquePath())) + .setFilePath(ingestJobState.getPathNormalizer().normalizePath(af.getUniquePath())) .setFileSizeBytes(af.getSize()) .setFileUploadUrl(authTokenResponse.getFileUploadUrl()) .setMd5(md5) @@ -733,7 +733,7 @@ class MalwareScanIngestModule implements FileIngestModule { if (!ingestJobState.isDoFileLookups() || ingestJobState.getIngestJobContext().fileIngestIsCancelled()) { return; } - + Thread.sleep(FILE_UPLOAD_RETRY_SLEEP_MILLIS); } } @@ -934,6 +934,7 @@ class MalwareScanIngestModule implements FileIngestModule { null, null, null, + null, false, false ); @@ -951,10 +952,12 @@ class MalwareScanIngestModule implements FileIngestModule { private boolean uploadUnknownFiles; private boolean doFileLookups; private final IngestJobContext ingestJobContext; + private final PathNormalizer pathNormalizer; - IngestJobState(IngestJobContext ingestJobContext, SleuthkitCase tskCase, FileTypeDetector fileTypeDetector, LicenseInfo licenseInfo, BlackboardArtifact.Type malwareType, boolean uploadUnknownFiles, boolean doFileLookups) { + 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(); @@ -1017,6 +1020,11 @@ class MalwareScanIngestModule implements FileIngestModule { 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 0000000000..f8a0a299a5 --- /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 sleuthkit 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 = ""; + private static final String ANONYMIZED_IP = ""; + private static final String ANONYMIZED_HOSTNAME = ""; + private static final String FORWARD_SLASH = "/"; + private static final String BACK_SLASH = "\\"; + + private static final Pattern USER_PATH_FORWARD_SLASH_REGEX = Pattern.compile("(? 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 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 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 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/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java b/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java deleted file mode 100644 index 75261ae43f..0000000000 --- a/Core/src/com/basistech/df/cybertriage/autopsy/malwarescan/UsernameAnonymizer.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Autopsy Forensic Browser - * - * Copyright 2023 Basis Technology Corp. - * Contact: carrier sleuthkit 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.HashSet; -import java.util.Locale; -import java.util.Set; -import java.util.logging.Level; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import org.apache.commons.lang3.StringUtils; -import org.sleuthkit.autopsy.coreutils.Logger; - -/** - * Utility class to anonymize username in paths also anonymizes hostname / ip - * from UNC paths - */ -class UsernameAnonymizer { - - private static final Logger LOGGER = Logger.getLogger(UsernameAnonymizer.class.getName()); - - private final String USER_PATH_FORWARD_SLASH_REGEX = "(?"); - } - String anonymousString = matcher.replaceAll(replacement); - - 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) { - Pattern pattern = WINDOWS_VERSION < 6 ? Pattern.compile(USER_PATH_BACK_SLASH_REGEX_XP, Pattern.CASE_INSENSITIVE) : Pattern.compile(USER_PATH_BACK_SLASH_REGEX, Pattern.CASE_INSENSITIVE); - Matcher matcher = pattern.matcher(stringWithUsername.toLowerCase(Locale.ENGLISH)); - String replacement = ""; - while (matcher.find()) { - replacement = String.format("$1%s$4", ""); - } - String anonymousString = matcher.replaceAll(replacement); - - return anonymousString; - } - - private String anonymizeServerFromUNCPath(String inputString) { - - Set 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)) { - if (isLocalIP(serverName)) { - anonymousString = StringUtils.replace(anonymousString, "\\" + serverName + "\\", "\\\\"); - anonymousString = StringUtils.replace(anonymousString, "/" + serverName + "/", "//"); - } - } else { - anonymousString = StringUtils.replace(anonymousString, "\\" + serverName + "\\", "\\\\"); - anonymousString = StringUtils.replace(anonymousString, "/" + serverName + "/", "//"); - } - - } - - 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; - } - } - -}