/*
* Autopsy Forensic Browser
*
* Copyright 2011-2016 Basis Technology Corp.
* Contact: carrier <at> sleuthkit <dot> org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.sleuthkit.autopsy.datamodel.accounts;
import com.google.common.collect.Range;
import com.google.common.collect.RangeMap;
import com.google.common.collect.TreeRangeMap;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import java.awt.event.ActionEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nonnull;
import javax.annotation.concurrent.Immutable;
import javax.swing.AbstractAction;
import javax.swing.Action;
import org.apache.commons.lang3.StringUtils;
import org.openide.nodes.Children;
import org.openide.nodes.Node;
import org.openide.nodes.NodeNotFoundException;
import org.openide.nodes.NodeOp;
import org.openide.nodes.Sheet;
import org.openide.util.NbBundle;
import org.openide.util.Utilities;
import org.openide.util.lookup.Lookups;
import org.sleuthkit.autopsy.casemodule.Case;
import org.sleuthkit.autopsy.corecomponents.DataResultTopComponent;
import org.sleuthkit.autopsy.datamodel.AutopsyItemVisitor;
import org.sleuthkit.autopsy.datamodel.AutopsyVisitableItem;
import org.sleuthkit.autopsy.datamodel.BlackboardArtifactNode;
import org.sleuthkit.autopsy.datamodel.CreditCards;
import org.sleuthkit.autopsy.datamodel.DataModelActionsFactory;
import org.sleuthkit.autopsy.datamodel.DisplayableItemNode;
import org.sleuthkit.autopsy.datamodel.DisplayableItemNodeVisitor;
import org.sleuthkit.autopsy.datamodel.NodeProperty;
import org.sleuthkit.autopsy.directorytree.DirectoryTreeTopComponent;
import org.sleuthkit.autopsy.ingest.IngestManager;
import org.sleuthkit.autopsy.ingest.ModuleDataEvent;
import org.sleuthkit.datamodel.AbstractFile;
import org.sleuthkit.datamodel.Account;
import org.sleuthkit.datamodel.BlackboardArtifact;
import org.sleuthkit.datamodel.BlackboardArtifact.ARTIFACT_TYPE;
import org.sleuthkit.datamodel.BlackboardAttribute;
import org.sleuthkit.datamodel.Content;
import org.sleuthkit.datamodel.SleuthkitCase;
import org.sleuthkit.datamodel.TskCoreException;
import org.sleuthkit.datamodel.TskData.DbType;
/**
* AutopsyVisitableItem for the Accounts section of the tree. All nodes,
* factories, and custom key class related to accounts are inner classes.
*/
final public class Accounts implements AutopsyVisitableItem {
private static final Logger LOGGER = Logger.getLogger(Accounts.class.getName());
@NbBundle.Messages("AccountsRootNode.name=Accounts")
final public static String NAME = Bundle.AccountsRootNode_name();
private SleuthkitCase skCase;
private final EventBus reviewStatusBus = new EventBus("ReviewStatusBus");
/**
* Should rejected accounts be shown in the accounts section of the tree.
*/
private boolean showRejected = false;
private final RejectAccounts rejectActionInstance;
private final ApproveAccounts approveActionInstance;
/**
* Constructor
*
* @param skCase The SleuthkitCase object to use for db queries.
*/
public Accounts(SleuthkitCase skCase) {
this.skCase = skCase;
this.rejectActionInstance = new RejectAccounts();
this.approveActionInstance = new ApproveAccounts();
}
@Override
public <T> T accept(AutopsyItemVisitor<T> v) {
return v.visit(this);
}
/**
* Get the clause that should be used in order to (not) filter out rejected
* results from db queries.
*
* @return A clause that will or will not filter out rejected artifacts
* based on the state of showRejected.
*/
private String getRejectedArtifactFilterClause() {
return showRejected ? " " : " AND blackboard_artifacts.review_status_id != " + BlackboardArtifact.ReviewStatus.REJECTED.getID() + " "; //NON-NLS
}
/**
* Gets a new Action that when invoked toggles showing rejected artifacts on
* or off.
*
* @return An Action that will toggle whether rejected artifacts are shown
* in the tree rooted by this Accounts instance.
*/
public Action newToggleShowRejectedAction() {
return new ToggleShowRejected();
}
/**
* Base class for children that are also observers of the reviewStatusBus.
* It
*
* @param <X> The type of keys used by this factory.
*/
private abstract class ObservingChildren<X> extends Children.Keys<X> {
/**
* Override of default constructor to force lazy creation of nodes, by
* concrete instances of ObservingChildren
*/
ObservingChildren() {
super(true);
}
/**
* Create of keys used by this Children object to represent the child
* nodes.
*/
abstract protected Collection<X> createKeys();
/**
* Refresh the keys for this Children
*/
void refreshKeys() {
setKeys(createKeys());
}
/**
* Handle a ReviewStatusChangeEvent
*
* @param event the ReviewStatusChangeEvent to handle.
*/
@Subscribe
abstract void handleReviewStatusChange(ReviewStatusChangeEvent event);
@Subscribe
abstract void handleDataAdded(ModuleDataEvent event);
@Override
protected void removeNotify() {
super.removeNotify();
reviewStatusBus.unregister(ObservingChildren.this);
}
@Override
protected void addNotify() {
super.addNotify();
refreshKeys();
reviewStatusBus.register(ObservingChildren.this);
}
}
/**
* Top-level node for the accounts tree
*/
@NbBundle.Messages({"Accounts.RootNode.displayName=Accounts"})
final public class AccountsRootNode extends DisplayableItemNode {
/**
* Creates child nodes for each account type in the db.
*/
final private class AccountTypeFactory extends ObservingChildren<String> {
/*
* The pcl is in this class because it has the easiest mechanisms to
* add and remove itself during its life cycles.
*/
private final PropertyChangeListener pcl = new PropertyChangeListener() {
@Override
public void propertyChange(PropertyChangeEvent evt) {
String eventType = evt.getPropertyName();
if (eventType.equals(IngestManager.IngestModuleEvent.DATA_ADDED.toString())) {
/**
* Checking for a current case is a stop gap measure
* until a different way of handling the closing of
* cases is worked out. Currently, remote events may be
* received for a case that is already closed.
*/
try {
Case.getCurrentCase();
/**
* Even with the check above, it is still possible
* that the case will be closed in a different
* thread before this code executes. If that
* happens, it is possible for the event to have a
* null oldValue.
*/
ModuleDataEvent eventData = (ModuleDataEvent) evt.getOldValue();
if (null != eventData
&& eventData.getBlackboardArtifactType().getTypeID() == ARTIFACT_TYPE.TSK_ACCOUNT.getTypeID()) {
reviewStatusBus.post(eventData);
}
} catch (IllegalStateException notUsed) {
// Case is closed, do nothing.
}
} else if (eventType.equals(IngestManager.IngestJobEvent.COMPLETED.toString())
|| eventType.equals(IngestManager.IngestJobEvent.CANCELLED.toString())) {
/**
* Checking for a current case is a stop gap measure
* until a different way of handling the closing of
* cases is worked out. Currently, remote events may be
* received for a case that is already closed.
*/
try {
Case.getCurrentCase();
refreshKeys();
} catch (IllegalStateException notUsed) {
// Case is closed, do nothing.
}
} else if (eventType.equals(Case.Events.CURRENT_CASE.toString())) {
// case was closed. Remove listeners so that we don't get called with a stale case handle
if (evt.getNewValue() == null) {
removeNotify();
skCase = null;
}
}
}
};
@Subscribe
@Override
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
refreshKeys();
}
@Subscribe
@Override
void handleDataAdded(ModuleDataEvent event) {
refreshKeys();
}
@Override
protected List<String> createKeys() {
List<String> list = new ArrayList<>();
try (SleuthkitCase.CaseDbQuery executeQuery = skCase.executeQuery(
"SELECT DISTINCT blackboard_attributes.value_text as account_type "
+ " FROM blackboard_attributes "
+ " WHERE blackboard_attributes.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_ACCOUNT_TYPE.getTypeID());
ResultSet resultSet = executeQuery.getResultSet()) {
while (resultSet.next()) {
String accountType = resultSet.getString("account_type");
list.add(accountType);
}
} catch (TskCoreException | SQLException ex) {
LOGGER.log(Level.SEVERE, "Error querying for account_types", ex);
}
return list;
}
@Override
protected Node[] createNodes(String key) {
try {
Account.Type accountType = Account.Type.valueOf(key);
switch (accountType) {
case CREDIT_CARD:
return new Node[]{new CreditCardNumberAccountTypeNode()};
default:
return new Node[]{new DefaultAccountTypeNode(key)};
}
} catch (IllegalArgumentException ex) {
LOGGER.log(Level.WARNING, "Unknown account type: {0}", key);
//Flesh out what happens with other account types here.
return new Node[]{new DefaultAccountTypeNode(key)};
}
}
@Override
protected void removeNotify() {
IngestManager.getInstance().removeIngestJobEventListener(pcl);
IngestManager.getInstance().removeIngestModuleEventListener(pcl);
Case.removePropertyChangeListener(pcl);
super.removeNotify();
}
@Override
protected void addNotify() {
IngestManager.getInstance().addIngestJobEventListener(pcl);
IngestManager.getInstance().addIngestModuleEventListener(pcl);
Case.addPropertyChangeListener(pcl);
super.addNotify();
refreshKeys();
}
}
public AccountsRootNode() {
super(Children.LEAF, Lookups.singleton(Accounts.this));
setChildren(Children.createLazy(AccountTypeFactory::new));
setName(Accounts.NAME);
setDisplayName(Bundle.Accounts_RootNode_displayName());
this.setIconBaseWithExtension("org/sleuthkit/autopsy/images/accounts.png"); //NON-NLS
}
@Override
public boolean isLeafTypeNode() {
return false;
}
@Override
public <T> T accept(DisplayableItemNodeVisitor<T> v) {
return v.visit(this);
}
@Override
public String getItemType() {
return getClass().getName();
}
}
/**
* Default Node class for unknown account types and account types that have
* no special behavior.
*/
final public class DefaultAccountTypeNode extends DisplayableItemNode {
private final String accountTypeName;
final private class DefaultAccountFactory extends ObservingChildren<Long> {
private DefaultAccountFactory() {
}
@Override
protected Collection<Long> createKeys() {
List<Long> list = new ArrayList<>();
String query
= "SELECT blackboard_artifacts.artifact_id " //NON-NLS
+ " FROM blackboard_artifacts " //NON-NLS
+ " JOIN blackboard_attributes ON blackboard_artifacts.artifact_id = blackboard_attributes.artifact_id " //NON-NLS
+ " WHERE blackboard_artifacts.artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_ACCOUNT.getTypeID() //NON-NLS
+ " AND blackboard_attributes.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_ACCOUNT_TYPE.getTypeID() //NON-NLS
+ " AND blackboard_attributes.value_text = '" + accountTypeName + "'" //NON-NLS
+ getRejectedArtifactFilterClause(); //NON-NLS
try (SleuthkitCase.CaseDbQuery results = skCase.executeQuery(query);
ResultSet rs = results.getResultSet();) {
while (rs.next()) {
list.add(rs.getLong("artifact_id")); //NON-NLS
}
} catch (TskCoreException | SQLException ex) {
LOGGER.log(Level.SEVERE, "Error querying for account artifacts.", ex); //NON-NLS
}
return list;
}
@Override
protected Node[] createNodes(Long t) {
try {
return new Node[]{new BlackboardArtifactNode(skCase.getBlackboardArtifact(t))};
} catch (TskCoreException ex) {
LOGGER.log(Level.SEVERE, "Error get black board artifact with id " + t, ex);
return new Node[0];
}
}
@Subscribe
@Override
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
refreshKeys();
}
@Subscribe
@Override
void handleDataAdded(ModuleDataEvent event) {
refreshKeys();
}
}
private DefaultAccountTypeNode(String accountTypeName) {
super(Children.LEAF);
this.accountTypeName = accountTypeName;
setChildren(Children.createLazy(DefaultAccountFactory::new));
setName(accountTypeName);
this.setIconBaseWithExtension("org/sleuthkit/autopsy/images/credit-cards.png"); //NON-NLS
}
@Override
public boolean isLeafTypeNode() {
return true;
}
@Override
public <T> T accept(DisplayableItemNodeVisitor<T> v) {
return v.visit(this);
}
@Override
public String getItemType() {
return getClass().getName();
}
}
/**
* Enum for the children under the credit card AccountTypeNode.
*/
private enum CreditCardViewMode {
BY_FILE,
BY_BIN;
}
/**
* Node for the Credit Card account type. *
*/
final public class CreditCardNumberAccountTypeNode extends DisplayableItemNode {
/**
* ChildFactory that makes nodes for the different account organizations
* (by file, by BIN)
*/
final private class ViewModeFactory extends ObservingChildren<CreditCardViewMode> {
@Override
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
}
@Override
void handleDataAdded(ModuleDataEvent event) {
}
/**
*
*/
@Override
protected List<CreditCardViewMode> createKeys() {
return Arrays.asList(CreditCardViewMode.values());
}
@Override
protected Node[] createNodes(CreditCardViewMode key) {
switch (key) {
case BY_BIN:
return new Node[]{new ByBINNode()};
case BY_FILE:
return new Node[]{new ByFileNode()};
default:
return new Node[0];
}
}
}
private CreditCardNumberAccountTypeNode() {
super(Children.LEAF);
setChildren(new ViewModeFactory());
setName(Account.Type.CREDIT_CARD.getDisplayName());
this.setIconBaseWithExtension("org/sleuthkit/autopsy/images/credit-cards.png"); //NON-NLS
}
@Override
public boolean isLeafTypeNode() {
return false;
}
@Override
public <T> T accept(DisplayableItemNodeVisitor<T> v) {
return v.visit(this);
}
@Override
public String getItemType() {
return getClass().getName();
}
}
/**
* Node that is the root of the "by file" accounts tree. Its children are
* FileWithCCNNodes.
*/
final public class ByFileNode extends DisplayableItemNode {
/**
* Factory for the children of the ByFiles Node.
*/
final private class FileWithCCNFactory extends ObservingChildren<FileWithCCN> {
@Subscribe
@Override
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
refreshKeys();
}
@Subscribe
@Override
void handleDataAdded(ModuleDataEvent event) {
refreshKeys();
}
@Override
protected List<FileWithCCN> createKeys() {
List<FileWithCCN> list = new ArrayList<>();
String query
= "SELECT blackboard_artifacts.obj_id," //NON-NLS
+ " solr_attribute.value_text AS solr_document_id, "; //NON-NLS
if(skCase.getDatabaseType().equals(DbType.POSTGRESQL)){
query += " string_agg(blackboard_artifacts.artifact_id::character varying, ',') AS artifact_IDs, " //NON-NLS
+ " string_agg(blackboard_artifacts.review_status_id::character varying, ',') AS review_status_ids, ";
} else {
query += " GROUP_CONCAT(blackboard_artifacts.artifact_id) AS artifact_IDs, " //NON-NLS
+ " GROUP_CONCAT(blackboard_artifacts.review_status_id) AS review_status_ids, ";
}
query += " COUNT( blackboard_artifacts.artifact_id) AS hits " //NON-NLS
+ " FROM blackboard_artifacts " //NON-NLS
+ " LEFT JOIN blackboard_attributes as solr_attribute ON blackboard_artifacts.artifact_id = solr_attribute.artifact_id " //NON-NLS
+ " AND solr_attribute.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_KEYWORD_SEARCH_DOCUMENT_ID.getTypeID() //NON-NLS
+ " LEFT JOIN blackboard_attributes as account_type ON blackboard_artifacts.artifact_id = account_type.artifact_id " //NON-NLS
+ " AND account_type.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_ACCOUNT_TYPE.getTypeID() //NON-NLS
+ " AND account_type.value_text = '" + Account.Type.CREDIT_CARD.name() + "'" //NON-NLS
+ " WHERE blackboard_artifacts.artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_ACCOUNT.getTypeID() //NON-NLS
+ getRejectedArtifactFilterClause()
+ " GROUP BY blackboard_artifacts.obj_id, solr_document_id " //NON-NLS
+ " ORDER BY hits DESC "; //NON-NLS
try (SleuthkitCase.CaseDbQuery results = skCase.executeQuery(query);
ResultSet rs = results.getResultSet();) {
while (rs.next()) {
list.add(new FileWithCCN(
rs.getLong("obj_id"), //NON-NLS
rs.getString("solr_document_id"), //NON-NLS
unGroupConcat(rs.getString("artifact_IDs"), Long::valueOf), //NON-NLS
rs.getLong("hits"), //NON-NLS
new HashSet<>(unGroupConcat(rs.getString("review_status_ids"), id -> BlackboardArtifact.ReviewStatus.withID(Integer.valueOf(id)))))); //NON-NLS
}
} catch (TskCoreException | SQLException ex) {
LOGGER.log(Level.SEVERE, "Error querying for files with ccn hits.", ex); //NON-NLS
}
return list;
}
@Override
protected Node[] createNodes(FileWithCCN key) {
//add all account artifacts for the file and the file itself to the lookup
try {
List<Object> lookupContents = new ArrayList<>();
for (long artId : key.artifactIDs) {
lookupContents.add(skCase.getBlackboardArtifact(artId));
}
AbstractFile abstractFileById = skCase.getAbstractFileById(key.getObjID());
lookupContents.add(abstractFileById);
return new Node[]{new FileWithCCNNode(key, abstractFileById, lookupContents.toArray())};
} catch (TskCoreException ex) {
LOGGER.log(Level.SEVERE, "Error getting content for file with ccn hits.", ex); //NON-NLS
return new Node[0];
}
}
}
private ByFileNode() {
super(Children.LEAF);
setChildren(Children.createLazy(FileWithCCNFactory::new));
setName("By File"); //NON-NLS
updateDisplayName();
this.setIconBaseWithExtension("org/sleuthkit/autopsy/images/file-icon.png"); //NON-NLS
reviewStatusBus.register(this);
}
@NbBundle.Messages({
"# {0} - number of children",
"Accounts.ByFileNode.displayName=By File ({0})"})
private void updateDisplayName() {
String query
= "SELECT count(*) FROM ( SELECT count(*) AS documents "
+ " FROM blackboard_artifacts " //NON-NLS
+ " LEFT JOIN blackboard_attributes as solr_attribute ON blackboard_artifacts.artifact_id = solr_attribute.artifact_id " //NON-NLS
+ " AND solr_attribute.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_KEYWORD_SEARCH_DOCUMENT_ID.getTypeID() //NON-NLS
+ " LEFT JOIN blackboard_attributes as account_type ON blackboard_artifacts.artifact_id = account_type.artifact_id " //NON-NLS
+ " AND account_type.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_ACCOUNT_TYPE.getTypeID() //NON-NLS
+ " AND account_type.value_text = '" + Account.Type.CREDIT_CARD.name() + "'" //NON-NLS
+ " WHERE blackboard_artifacts.artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_ACCOUNT.getTypeID() //NON-NLS
+ getRejectedArtifactFilterClause()
+ " GROUP BY blackboard_artifacts.obj_id, solr_attribute.value_text ) AS foo";
try (SleuthkitCase.CaseDbQuery results = skCase.executeQuery(query);
ResultSet rs = results.getResultSet();) {
while (rs.next()) {
if(skCase.getDatabaseType().equals(DbType.POSTGRESQL)){
setDisplayName(Bundle.Accounts_ByFileNode_displayName(rs.getLong("count")));
} else {
setDisplayName(Bundle.Accounts_ByFileNode_displayName(rs.getLong("count(*)")));
}
}
} catch (TskCoreException | SQLException ex) {
LOGGER.log(Level.SEVERE, "Error querying for files with ccn hits.", ex); //NON-NLS
}
}
@Override
public boolean isLeafTypeNode() {
return true;
}
@Override
public <T> T accept(DisplayableItemNodeVisitor<T> v) {
return v.visit(this);
}
@Override
public String getItemType() {
return getClass().getName();
}
@Subscribe
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
updateDisplayName();
}
@Subscribe
void handleDataAdded(ModuleDataEvent event) {
updateDisplayName();
}
}
/**
* Node that is the root of the "By BIN" accounts tree. Its children are
* BINNodes.
*/
final public class ByBINNode extends DisplayableItemNode {
/**
* Factory that generates the children of the ByBin node.
*/
final private class BINFactory extends ObservingChildren<BinResult> {
@Subscribe
@Override
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
refreshKeys();
}
@Subscribe
@Override
void handleDataAdded(ModuleDataEvent event) {
refreshKeys();
}
@Override
protected List<BinResult> createKeys() {
List<BinResult> list = new ArrayList<>();
RangeMap<Integer, BinResult> binRanges = TreeRangeMap.create();
String query
= "SELECT SUBSTR(blackboard_attributes.value_text,1,8) AS BIN, " //NON-NLS
+ " COUNT(blackboard_artifacts.artifact_id) AS count " //NON-NLS
+ " FROM blackboard_artifacts " //NON-NLS
+ " JOIN blackboard_attributes ON blackboard_artifacts.artifact_id = blackboard_attributes.artifact_id" //NON-NLS
+ " WHERE blackboard_artifacts.artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_ACCOUNT.getTypeID() //NON-NLS
+ " AND blackboard_attributes.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_CARD_NUMBER.getTypeID() //NON-NLS
+ getRejectedArtifactFilterClause()
+ " GROUP BY BIN " //NON-NLS
+ " ORDER BY BIN "; //NON-NLS
try (SleuthkitCase.CaseDbQuery results = skCase.executeQuery(query)) {
ResultSet resultSet = results.getResultSet();
//sort all te individual bins in to the ranges
while (resultSet.next()) {
final Integer bin = Integer.valueOf(resultSet.getString("BIN"));
long count = resultSet.getLong("count");
BINRange binRange = (BINRange) CreditCards.getBINInfo(bin);
BinResult previousResult = binRanges.get(bin);
if (previousResult != null) {
binRanges.remove(Range.closed(previousResult.getBINStart(), previousResult.getBINEnd()));
count += previousResult.getCount();
}
if (binRange != null) {
binRanges.put(Range.closed(binRange.getBINstart(), binRange.getBINend()), new BinResult(count, binRange));
} else {
binRanges.put(Range.closed(bin, bin), new BinResult(count, bin, bin));
}
}
binRanges.asMapOfRanges().values().forEach(list::add);
} catch (TskCoreException | SQLException ex) {
LOGGER.log(Level.SEVERE, "Error querying for BINs.", ex); //NON-NLS
}
return list;
}
@Override
protected Node[] createNodes(BinResult key) {
return new Node[]{new BINNode(key)};
}
}
private ByBINNode() {
super(Children.LEAF);
setChildren(Children.createLazy(BINFactory::new));
setName("By BIN"); //NON-NLS
updateDisplayName();
this.setIconBaseWithExtension("org/sleuthkit/autopsy/images/bank.png"); //NON-NLS
reviewStatusBus.register(this);
}
@NbBundle.Messages({
"# {0} - number of children",
"Accounts.ByBINNode.displayName=By BIN ({0})"})
private void updateDisplayName() {
String query
= "SELECT count(distinct SUBSTR(blackboard_attributes.value_text,1,8)) AS BINs " //NON-NLS
+ " FROM blackboard_artifacts " //NON-NLS
+ " JOIN blackboard_attributes ON blackboard_artifacts.artifact_id = blackboard_attributes.artifact_id" //NON-NLS
+ " WHERE blackboard_artifacts.artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_ACCOUNT.getTypeID() //NON-NLS
+ " AND blackboard_attributes.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_CARD_NUMBER.getTypeID() //NON-NLS
+ getRejectedArtifactFilterClause(); //NON-NLS
try (SleuthkitCase.CaseDbQuery results = skCase.executeQuery(query)) {
ResultSet resultSet = results.getResultSet();
while (resultSet.next()) {
setDisplayName(Bundle.Accounts_ByBINNode_displayName(resultSet.getLong("BINs")));
}
} catch (TskCoreException | SQLException ex) {
LOGGER.log(Level.SEVERE, "Error querying for BINs.", ex); //NON-NLS
}
}
@Override
public boolean isLeafTypeNode() {
return false;
}
@Override
public <T> T accept(DisplayableItemNodeVisitor<T> v) {
return v.visit(this);
}
@Override
public String getItemType() {
return getClass().getName();
}
@Subscribe
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
updateDisplayName();
}
@Subscribe
void handleDataAdded(ModuleDataEvent event) {
updateDisplayName();
}
}
/**
* DataModel for a child of the ByFileNode. Represents a file(chunk) and its
* associated accounts.
*/
@Immutable
final private static class FileWithCCN {
@Override
public int hashCode() {
int hash = 5;
hash = 79 * hash + (int) (this.objID ^ (this.objID >>> 32));
hash = 79 * hash + Objects.hashCode(this.keywordSearchDocID);
hash = 79 * hash + Objects.hashCode(this.artifactIDs);
hash = 79 * hash + (int) (this.hits ^ (this.hits >>> 32));
hash = 79 * hash + Objects.hashCode(this.statuses);
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final FileWithCCN other = (FileWithCCN) obj;
if (this.objID != other.objID) {
return false;
}
if (this.hits != other.hits) {
return false;
}
if (!Objects.equals(this.keywordSearchDocID, other.keywordSearchDocID)) {
return false;
}
if (!Objects.equals(this.artifactIDs, other.artifactIDs)) {
return false;
}
if (!Objects.equals(this.statuses, other.statuses)) {
return false;
}
return true;
}
private final long objID;
private final String keywordSearchDocID;
private final List<Long> artifactIDs;
private final long hits;
private final Set<BlackboardArtifact.ReviewStatus> statuses;
private FileWithCCN(long objID, String solrDocID, List<Long> artifactIDs, long hits, Set<BlackboardArtifact.ReviewStatus> statuses) {
this.objID = objID;
this.keywordSearchDocID = solrDocID;
this.artifactIDs = artifactIDs;
this.hits = hits;
this.statuses = statuses;
}
/**
* Get the object ID of the file.
*
* @return the object ID of the file.
*/
public long getObjID() {
return objID;
}
/**
* Get the keyword search docuement id. This is used for unnalocated
* files to limit results to one chunk/page
*
* @return the keyword search document id.
*/
public String getkeywordSearchDocID() {
return keywordSearchDocID;
}
/**
* Get the artifact ids of the account artifacts from this file.
*
* @return the artifact ids of the account artifacts from this file.
*/
public List<Long> getArtifactIDs() {
return artifactIDs;
}
/**
* Get the number of account artifacts from this file.
*
* @return the number of account artifacts from this file.
*/
public long getHits() {
return hits;
}
/**
* Get the status(s) of the account artifacts from this file.
*
* @return the status(s) of the account artifacts from this file.
*/
public Set<BlackboardArtifact.ReviewStatus> getStatuses() {
return statuses;
}
}
/**
* TODO: this was copy-pasted from timeline. Is there a single accessible
* place it should go?
*
*
* take the result of a group_concat SQLite operation and split it into a
* set of X using the mapper to to convert from string to X
*
* @param <X> the type of elements to return
* @param groupConcat a string containing the group_concat result ( a comma
* separated list)
* @param mapper a function from String to X
*
* @return a Set of X, each element mapped from one element of the original
* comma delimited string
*/
static <X> List<X> unGroupConcat(String groupConcat, Function<String, X> mapper) {
return StringUtils.isBlank(groupConcat) ? Collections.emptyList()
: Stream.of(groupConcat.split(",")) //NON-NLS
.map(mapper::apply)
.collect(Collectors.toList());
}
/**
* Node that represents a file or chunk of an unallocated space file.
*/
final public class FileWithCCNNode extends DisplayableItemNode {
private final FileWithCCN fileKey;
private final String fileName;
/**
* Constructor
*
* @param key The FileWithCCN that backs this node.
* @param content The Content object the key represents.
* @param lookupContents The contents of this Node's lookup. It should
* contain the content object and the account
* artifacts.
*/
@NbBundle.Messages({
"# {0} - raw file name",
"# {1} - solr chunk id",
"Accounts.FileWithCCNNode.unallocatedSpaceFile.displayName={0}_chunk_{1}"})
private FileWithCCNNode(FileWithCCN key, Content content, Object[] lookupContents) {
super(Children.LEAF, Lookups.fixed(lookupContents));
this.fileKey = key;
this.fileName = (key.getkeywordSearchDocID() == null)
? content.getName()
: Bundle.Accounts_FileWithCCNNode_unallocatedSpaceFile_displayName(content.getName(), StringUtils.substringAfter(key.getkeywordSearchDocID(), "_")); //NON-NLS
setName(fileName + key.getObjID());
setDisplayName(fileName);
}
@Override
public boolean isLeafTypeNode() {
return true;
}
@Override
public <T> T accept(DisplayableItemNodeVisitor<T> v) {
return v.visit(this);
}
@Override
public String getItemType() {
return getClass().getName();
}
@Override
@NbBundle.Messages({
"Accounts.FileWithCCNNode.nameProperty.displayName=File",
"Accounts.FileWithCCNNode.accountsProperty.displayName=Accounts",
"Accounts.FileWithCCNNode.statusProperty.displayName=Status",
"Accounts.FileWithCCNNode.noDescription=no description"})
protected Sheet createSheet() {
Sheet s = super.createSheet();
Sheet.Set ss = s.get(Sheet.PROPERTIES);
if (ss == null) {
ss = Sheet.createPropertiesSet();
s.put(ss);
}
ss.put(new NodeProperty<>(Bundle.Accounts_FileWithCCNNode_nameProperty_displayName(),
Bundle.Accounts_FileWithCCNNode_nameProperty_displayName(),
Bundle.Accounts_FileWithCCNNode_noDescription(),
fileName));
ss.put(new NodeProperty<>(Bundle.Accounts_FileWithCCNNode_accountsProperty_displayName(),
Bundle.Accounts_FileWithCCNNode_accountsProperty_displayName(),
Bundle.Accounts_FileWithCCNNode_noDescription(),
fileKey.getHits()));
ss.put(new NodeProperty<>(Bundle.Accounts_FileWithCCNNode_statusProperty_displayName(),
Bundle.Accounts_FileWithCCNNode_statusProperty_displayName(),
Bundle.Accounts_FileWithCCNNode_noDescription(),
fileKey.getStatuses().stream()
.map(BlackboardArtifact.ReviewStatus::getDisplayName)
.collect(Collectors.joining(", ")))); //NON-NLS
return s;
}
@Override
public Action[] getActions(boolean context) {
Action[] actions = super.getActions(context);
ArrayList<Action> arrayList = new ArrayList<>();
arrayList.addAll(Arrays.asList(actions));
try {
arrayList.addAll(DataModelActionsFactory.getActions(Accounts.this.skCase.getContentById(fileKey.getObjID()), false));
} catch (TskCoreException ex) {
LOGGER.log(Level.SEVERE, "Error gettung content by id", ex);
}
arrayList.add(approveActionInstance);
arrayList.add(rejectActionInstance);
return arrayList.toArray(new Action[arrayList.size()]);
}
}
final public class BINNode extends DisplayableItemNode {
/**
* Creates the nodes for the credit card numbers
*/
final private class CreditCardNumberFactory extends ObservingChildren<Long> {
@Subscribe
@Override
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
refreshKeys();
//make sure to refresh the nodes for artifacts that changed statuses.
event.artifacts.stream().map(BlackboardArtifact::getArtifactID).forEach(this::refreshKey);
}
@Subscribe
@Override
void handleDataAdded(ModuleDataEvent event) {
refreshKeys();
}
@Override
protected List<Long> createKeys() {
List<Long> list = new ArrayList<>();
String query
= "SELECT blackboard_artifacts.artifact_id " //NON-NLS
+ " FROM blackboard_artifacts " //NON-NLS
+ " JOIN blackboard_attributes ON blackboard_artifacts.artifact_id = blackboard_attributes.artifact_id " //NON-NLS
+ " WHERE blackboard_artifacts.artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_ACCOUNT.getTypeID() //NON-NLS
+ " AND blackboard_attributes.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_CARD_NUMBER.getTypeID() //NON-NLS
+ " AND blackboard_attributes.value_text >= '" + bin.getBINStart() + "' AND blackboard_attributes.value_text < '" + (bin.getBINEnd() + 1) + "'" //NON-NLS
+ getRejectedArtifactFilterClause()
+ " ORDER BY blackboard_attributes.value_text"; //NON-NLS
try (SleuthkitCase.CaseDbQuery results = skCase.executeQuery(query);
ResultSet rs = results.getResultSet();) {
while (rs.next()) {
list.add(rs.getLong("artifact_id")); //NON-NLS
}
} catch (TskCoreException | SQLException ex) {
LOGGER.log(Level.SEVERE, "Error querying for account artifacts.", ex); //NON-NLS
}
return list;
}
@Override
protected Node[] createNodes(Long artifactID) {
if (skCase == null) {
return new Node[0];
}
try {
BlackboardArtifact art = skCase.getBlackboardArtifact(artifactID);
return new Node[]{new AccountArtifactNode(art)};
} catch (TskCoreException ex) {
LOGGER.log(Level.WARNING, "Error creating BlackboardArtifactNode for artifact with ID " + artifactID, ex); //NON-NLS
return new Node[0];
}
}
}
private final BinResult bin;
private BINNode(BinResult bin) {
super(Children.LEAF);
this.bin = bin;
setChildren(Children.createLazy(CreditCardNumberFactory::new));
setName(getBinRangeString());
updateDisplayName();
this.setIconBaseWithExtension("org/sleuthkit/autopsy/images/bank.png"); //NON-NLS
reviewStatusBus.register(this);
}
@Subscribe
void handleReviewStatusChange(ReviewStatusChangeEvent event) {
updateDisplayName();
}
@Subscribe
void handleDataAdded(ModuleDataEvent event) {
updateDisplayName();
}
private void updateDisplayName() {
String query
= "SELECT count(blackboard_artifacts.artifact_id ) AS count" //NON-NLS
+ " FROM blackboard_artifacts " //NON-NLS
+ " JOIN blackboard_attributes ON blackboard_artifacts.artifact_id = blackboard_attributes.artifact_id " //NON-NLS
+ " WHERE blackboard_artifacts.artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_ACCOUNT.getTypeID() //NON-NLS
+ " AND blackboard_attributes.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_CARD_NUMBER.getTypeID() //NON-NLS
+ " AND blackboard_attributes.value_text >= '" + bin.getBINStart() + "' AND blackboard_attributes.value_text < '" + (bin.getBINEnd() + 1) + "'" //NON-NLS
+ getRejectedArtifactFilterClause();
try (SleuthkitCase.CaseDbQuery results = skCase.executeQuery(query);
ResultSet rs = results.getResultSet();) {
while (rs.next()) {
setDisplayName(getBinRangeString() + " (" + rs.getLong("count") + ")"); //NON-NLS
}
} catch (TskCoreException | SQLException ex) {
LOGGER.log(Level.SEVERE, "Error querying for account artifacts.", ex); //NON-NLS
}
}
private String getBinRangeString() {
if (bin.getBINStart() == bin.getBINEnd()) {
return Integer.toString(bin.getBINStart());
} else {
return bin.getBINStart() + "-" + StringUtils.difference(bin.getBINStart() + "", bin.getBINEnd() + "");
}
}
@Override
public boolean isLeafTypeNode() {
return true;
}
@Override
public <T> T accept(DisplayableItemNodeVisitor<T> v) {
return v.visit(this);
}
@Override
public String getItemType() {
return getClass().getName();
}
private Sheet.Set getPropertySet(Sheet s) {
Sheet.Set ss = s.get(Sheet.PROPERTIES);
if (ss == null) {
ss = Sheet.createPropertiesSet();
s.put(ss);
}
return ss;
}
@Override
@NbBundle.Messages({
"Accounts.BINNode.binProperty.displayName=Bank Identifier Number",
"Accounts.BINNode.accountsProperty.displayName=Accounts",
"Accounts.BINNode.cardTypeProperty.displayName=Payment Card Type",
"Accounts.BINNode.schemeProperty.displayName=Credit Card Scheme",
"Accounts.BINNode.brandProperty.displayName=Brand",
"Accounts.BINNode.bankProperty.displayName=Bank",
"Accounts.BINNode.bankCityProperty.displayName=Bank City",
"Accounts.BINNode.bankCountryProperty.displayName=Bank Country",
"Accounts.BINNode.bankPhoneProperty.displayName=Bank Phone #",
"Accounts.BINNode.bankURLProperty.displayName=Bank URL",
"Accounts.BINNode.noDescription=no description"})
protected Sheet createSheet() {
Sheet sheet = super.createSheet();
Sheet.Set properties = getPropertySet(sheet);
properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_binProperty_displayName(),
Bundle.Accounts_BINNode_binProperty_displayName(),
Bundle.Accounts_BINNode_noDescription(),
getBinRangeString()));
properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_accountsProperty_displayName(),
Bundle.Accounts_BINNode_accountsProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
bin.getCount()));
//add optional properties if they are available
if (bin.hasDetails()) {
bin.getCardType().ifPresent(cardType -> properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_cardTypeProperty_displayName(),
Bundle.Accounts_BINNode_cardTypeProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
cardType)));
bin.getScheme().ifPresent(scheme -> properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_schemeProperty_displayName(),
Bundle.Accounts_BINNode_schemeProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
scheme)));
bin.getBrand().ifPresent(brand -> properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_brandProperty_displayName(),
Bundle.Accounts_BINNode_brandProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
brand)));
bin.getBankName().ifPresent(bankName -> properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_bankProperty_displayName(),
Bundle.Accounts_BINNode_bankProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
bankName)));
bin.getBankCity().ifPresent(bankCity -> properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_bankCityProperty_displayName(),
Bundle.Accounts_BINNode_bankCityProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
bankCity)));
bin.getCountry().ifPresent(country -> properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_bankCountryProperty_displayName(),
Bundle.Accounts_BINNode_bankCountryProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
country)));
bin.getBankPhoneNumber().ifPresent(phoneNumber -> properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_bankPhoneProperty_displayName(),
Bundle.Accounts_BINNode_bankPhoneProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
phoneNumber)));
bin.getBankURL().ifPresent(url -> properties.put(new NodeProperty<>(Bundle.Accounts_BINNode_bankURLProperty_displayName(),
Bundle.Accounts_BINNode_bankURLProperty_displayName(), Bundle.Accounts_BINNode_noDescription(),
url)));
}
return sheet;
}
}
/**
* Data model item to back the BINNodes in the tree. Has the number of
* accounts found with the BIN.
*/
@Immutable
final static private class BinResult implements CreditCards.BankIdentificationNumber {
@Override
public int hashCode() {
int hash = 3;
hash = 97 * hash + this.binEnd;
hash = 97 * hash + this.binStart;
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final BinResult other = (BinResult) obj;
if (this.binEnd != other.binEnd) {
return false;
}
if (this.binStart != other.binStart) {
return false;
}
return true;
}
/**
* The number of accounts with this BIN
*/
private final long count;
private final BINRange binRange;
private final int binEnd;
private final int binStart;
private BinResult(long count, @Nonnull BINRange binRange) {
this.count = count;
this.binRange = binRange;
binStart = binRange.getBINstart();
binEnd = binRange.getBINend();
}
private BinResult(long count, int start, int end) {
this.count = count;
this.binRange = null;
binStart = start;
binEnd = end;
}
int getBINStart() {
return binStart;
}
int getBINEnd() {
return binEnd;
}
long getCount() {
return count;
}
boolean hasDetails() {
return binRange != null;
}
@Override
public Optional<Integer> getNumberLength() {
return binRange.getNumberLength();
}
@Override
public Optional<String> getBankCity() {
return binRange.getBankCity();
}
@Override
public Optional<String> getBankName() {
return binRange.getBankName();
}
@Override
public Optional<String> getBankPhoneNumber() {
return binRange.getBankPhoneNumber();
}
@Override
public Optional<String> getBankURL() {
return binRange.getBankURL();
}
@Override
public Optional<String> getBrand() {
return binRange.getBrand();
}
@Override
public Optional<String> getCardType() {
return binRange.getCardType();
}
@Override
public Optional<String> getCountry() {
return binRange.getCountry();
}
@Override
public Optional<String> getScheme() {
return binRange.getScheme();
}
}
final private class AccountArtifactNode extends BlackboardArtifactNode {
private final BlackboardArtifact artifact;
private AccountArtifactNode(BlackboardArtifact artifact) {
super(artifact, "org/sleuthkit/autopsy/images/credit-card.png"); //NON-NLS
this.artifact = artifact;
setName("" + this.artifact.getArtifactID());
}
@Override
public Action[] getActions(boolean context) {
List<Action> actionsList = new ArrayList<>();
actionsList.addAll(Arrays.asList(super.getActions(context)));
actionsList.add(approveActionInstance);
actionsList.add(rejectActionInstance);
return actionsList.toArray(new Action[actionsList.size()]);
}
@Override
protected Sheet createSheet() {
Sheet sheet = super.createSheet();
Sheet.Set properties = sheet.get(Sheet.PROPERTIES);
if (properties == null) {
properties = Sheet.createPropertiesSet();
sheet.put(properties);
}
properties.put(new NodeProperty<>(Bundle.Accounts_FileWithCCNNode_statusProperty_displayName(),
Bundle.Accounts_FileWithCCNNode_statusProperty_displayName(),
Bundle.Accounts_FileWithCCNNode_noDescription(),
artifact.getReviewStatus().getDisplayName()));
return sheet;
}
}
private final class ToggleShowRejected extends AbstractAction {
@NbBundle.Messages("ToggleShowRejected.name=Show Rejected Results")
ToggleShowRejected() {
super(Bundle.ToggleShowRejected_name());
}
@Override
public void actionPerformed(ActionEvent e) {
showRejected = !showRejected;
reviewStatusBus.post(new ReviewStatusChangeEvent(Collections.emptySet(), null));
}
}
private abstract class ReviewStatusAction extends AbstractAction {
private final BlackboardArtifact.ReviewStatus newStatus;
private ReviewStatusAction(String displayName, BlackboardArtifact.ReviewStatus newStatus) {
super(displayName);
this.newStatus = newStatus;
}
@Override
public void actionPerformed(ActionEvent e) {
/* get paths for selected nodes to reselect after applying review
* status change */
List<String[]> selectedPaths = Utilities.actionsGlobalContext().lookupAll(Node.class).stream()
.map(node -> {
String[] createPath;
/*
* If the we are rejecting and not showing rejected
* results, then the selected node, won't exist any
* more, so we select the previous one in stead.
*/
if (newStatus == BlackboardArtifact.ReviewStatus.REJECTED && showRejected == false) {
List<Node> siblings = Arrays.asList(node.getParentNode().getChildren().getNodes());
if (siblings.size() > 1) {
int indexOf = siblings.indexOf(node);
//there is no previous for the first node, so instead we select the next one
Node sibling = indexOf > 0
? siblings.get(indexOf - 1)
: siblings.get(Integer.max(indexOf + 1, siblings.size() - 1));
createPath = NodeOp.createPath(sibling, null);
} else {
/* if there are no other siblings to select,
* just return null, but note we need to filter
* this out of stream below */
return null;
}
} else {
createPath = NodeOp.createPath(node, null);
}
//for the reselect to work we need to strip off the first part of the path.
return Arrays.copyOfRange(createPath, 1, createPath.length);
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
//change status of selected artifacts
final Collection<? extends BlackboardArtifact> artifacts = Utilities.actionsGlobalContext().lookupAll(BlackboardArtifact.class);
artifacts.forEach(artifact -> {
try {
skCase.setReviewStatus(artifact, newStatus);
} catch (TskCoreException ex) {
LOGGER.log(Level.SEVERE, "Error changing artifact review status.", ex); //NON-NLS
}
});
//post event
reviewStatusBus.post(new ReviewStatusChangeEvent(artifacts, newStatus));
final DataResultTopComponent directoryListing = DirectoryTreeTopComponent.findInstance().getDirectoryListing();
final Node rootNode = directoryListing.getRootNode();
//convert paths back to nodes
List<Node> toArray = new ArrayList<>();
selectedPaths.forEach(path -> {
try {
toArray.add(NodeOp.findPath(rootNode, path));
} catch (NodeNotFoundException ex) {
//just ingnore paths taht don't exist. this is expected since we are rejecting
}
});
//select nodes
directoryListing.setSelectedNodes(toArray.toArray(new Node[toArray.size()]));
}
}
final private class ApproveAccounts extends ReviewStatusAction {
@NbBundle.Messages({"ApproveAccountsAction.name=Approve Accounts"})
private ApproveAccounts() {
super(Bundle.ApproveAccountsAction_name(), BlackboardArtifact.ReviewStatus.APPROVED);
}
}
final private class RejectAccounts extends ReviewStatusAction {
@NbBundle.Messages({"RejectAccountsAction.name=Reject Accounts"})
private RejectAccounts() {
super(Bundle.RejectAccountsAction_name(), BlackboardArtifact.ReviewStatus.REJECTED);
}
}
private class ReviewStatusChangeEvent {
Collection<? extends BlackboardArtifact> artifacts;
BlackboardArtifact.ReviewStatus newReviewStatus;
public ReviewStatusChangeEvent(Collection<? extends BlackboardArtifact> artifacts, BlackboardArtifact.ReviewStatus newReviewStatus) {
this.artifacts = artifacts;
this.newReviewStatus = newReviewStatus;
}
}
}