diff --git a/entity-cluster-bot/README.md b/entity-cluster-bot/README.md index 6ff07bf7..e2e462ac 100644 --- a/entity-cluster-bot/README.md +++ b/entity-cluster-bot/README.md @@ -34,27 +34,46 @@ It store the metadata of the shared graph and also manage the mutex (There is on The mutex also has a expire time of 10s at application level and a dynamo ttl at infrastructure level to avoid the mutex being lock if an intance is shutdown in the forta network. The Mutex only use dynamo write capacity -Definition: +#### set up a dynamo db on AWS +1. create table +table name : your-table-name Table class: DynamoDB standard Partition Key: itemId(String) Sort Key: sortKey(String) +Customize settings +DynamoDB standard +Read capacity : minimum 2 +Write capacity : minimum 2 +leave the rest as default +Create table + +2. Set up the ttl: ttl feature: on ttl name field: expiresAt -Capacity: 2 write, 2 read +place the instance in the same region as the s3 bucket ### S3_BUCKET= "prod-research-bot-data" S3 bucket to store the shared graph, one chared graph per chain. -Definition: -Just the standard s3 +1. create a S3 bucket +bucket name: your-bucket-name +Leave the rest as default +creatre bucket -### Infrastructure cost -Each instance updates the graph every TX_SAVE_STEP, asking for the mutex, saving metadata, reading from s3, writing in s3 and releasing the mutex all these operations cost $$$. The lower the TX_SAVE_STEP, the more accurate the alert are as the -instances "knows what is hapening in the other" but there are more operation over the infrastructure so the cost are higher. By design there is a relationship betweeen cost and accuracy. +### Set up IAM permission +#### Create a policy for the bot +1. go to IAM +2. go to policies +3. create policy +4. go to JSON tab +5. paste the following policy +6. replace the bucket name and the table name with the ones you created (S3_ARN and TABLE_ARN) ARN(Amazon Ressource Name) can be found in the bucket and table details. +7. click on next +8. Set up the name of the policy +9. click on create policy -### IAM permission Keep them as low as possible, here a template: @@ -73,7 +92,7 @@ Keep them as low as possible, here a template: "dynamodb:Query", "dynamodb:UpdateItem" ], - "Resource": "HIDDEN" + "Resource": "TABLE_ARN" }, { "Sid": "VisualEditor1", @@ -82,11 +101,40 @@ Keep them as low as possible, here a template: "s3:PutObject", "s3:GetObject" ], - "Resource": "HIDDEN" + "Resource": "S3_ARN" } ] } +#### Assign permission to a user +1. go to IAM +2. go to users +3. create or select a user +4. go to permissions tab +5. click on add permission +6. click on attach existing policies directly +7. search for the policy you created +8. click on next +9. click on add permission + + +#### Get the credentials +1. go to IAM +2. go to users +3. select a user +4. go to security credentials tab +5. click on create access key +6. click on show +7. copy the access key and the secret key +8. paste them in the secret.json file + + +### Infrastructure cost +Each instance updates the graph every TX_SAVE_STEP, asking for the mutex, saving metadata, reading from s3, writing in s3 and releasing the mutex all these operations cost $$$. The lower the TX_SAVE_STEP, the more accurate the alert are as the +instances "knows what is hapening in the other" but there are more operation over the infrastructure so the cost are higher. By design there is a relationship betweeen cost and accuracy. + + +## Credentials configuration Credential should be in secret.json, a template to start could be: { @@ -102,6 +150,9 @@ Credential should be in secret.json, a template to start could be: ZETTABLOCK for alert stats AWS.* infrastructure +### constants.py +you may have to change the constants.py file to match your infrastructure, zone and ressource names. + ## RPC Timeout Web3 RPC call are time consuming, there is now a HTTP_RPC_TIMEOUT=2 config to avoid waiting to long if we hit a slow server from the provider. If the rpc call doesn't finish in 2 second it will raise a exception and will continue with the next transaction. usually a rpc call should be 500ms diff --git a/entity-cluster-bot/src/agent.py b/entity-cluster-bot/src/agent.py index c4db6ce7..832624ef 100644 --- a/entity-cluster-bot/src/agent.py +++ b/entity-cluster-bot/src/agent.py @@ -18,11 +18,38 @@ load_dotenv() try: - from src.constants import MAX_AGE_IN_DAYS, MAX_NONCE, GRAPH_KEY, ONE_WAY_WEI_TRANSFER_THRESHOLD, NEW_FUNDED_MAX_WEI_TRANSFER_THRESHOLD, NEW_FUNDED_MAX_NONCE, TX_SAVE_STEP, HTTP_RPC_TIMEOUT, PROFILING, BOT_ID, PROD_TAG + from src.constants import ( + MAX_AGE_IN_DAYS, + MAX_NONCE, + GRAPH_KEY, + ONE_WAY_WEI_TRANSFER_THRESHOLD, + NEW_FUNDED_MAX_WEI_TRANSFER_THRESHOLD, + NEW_FUNDED_MAX_NONCE, + TX_SAVE_STEP, + HTTP_RPC_TIMEOUT, + PROFILING, + BOT_ID, + PROD_TAG, + SEVERITY_ALERT_FILTER, + MALICIOUS_SMART_CONTRACT_BOT_ID, + CLUSTER_SENDER, + ) from src.persistance import DynamoPersistance from src.storage import get_secrets except ModuleNotFoundError: - from constants import MAX_AGE_IN_DAYS, MAX_NONCE, GRAPH_KEY, ONE_WAY_WEI_TRANSFER_THRESHOLD, NEW_FUNDED_MAX_WEI_TRANSFER_THRESHOLD, NEW_FUNDED_MAX_NONCE, TX_SAVE_STEP, HTTP_RPC_TIMEOUT, PROFILING, BOT_ID, PROD_TAG + from constants import ( + MAX_AGE_IN_DAYS, + MAX_NONCE, + GRAPH_KEY, + ONE_WAY_WEI_TRANSFER_THRESHOLD, + NEW_FUNDED_MAX_WEI_TRANSFER_THRESHOLD, + NEW_FUNDED_MAX_NONCE, + TX_SAVE_STEP, + HTTP_RPC_TIMEOUT, + PROFILING, + BOT_ID, + PROD_TAG, + ) from persistance import DynamoPersistance from storage import get_secrets @@ -337,6 +364,79 @@ def real_handle_transaction(self, transaction_event): def persist_state(self): self.persistance.persist(self.GRAPH, GRAPH_KEY, EntityClusterAgent.prune_graph) -entity_cluster_agent = EntityClusterAgent(DynamoPersistance(PROD_TAG, web3.eth.chain_id), TX_SAVE_STEP, web3.eth.chain_id) -def handle_transaction(transaction_event: forta_agent.transaction_event.TransactionEvent) -> list: + + def provide_handle_alert(self, alert_event: forta_agent.alert_event.AlertEvent, cluster_sender=CLUSTER_SENDER): + logging.debug("provide_handle_alert called") + + if alert_event.chain_id != self.chain_id: + logging.debug("Alert not processed because it is not from the same chain") + else: + if alert_event.bot_id != MALICIOUS_SMART_CONTRACT_BOT_ID: + logging.debug( + f"Alert not processed not monitoring that bot {alert_event.bot_id} - alert id : {alert_event.alert.alert_id} - chain id {self.chain_id}" + ) + else: + if alert_event.alert.severity != SEVERITY_ALERT_FILTER: + logging.debug( + f"Alert not processed not monitoring that severity {alert_event.alert.severity} - alert id : {alert_event.alert.alert_id} - chain id {self.chain_id}" + ) + else: + logging.debug( + f"Processing alert {alert_event.alert.alert_id} - chain id {self.chain_id} - bot id {alert_event.bot_id} - severity {alert_event.alert.severity}" + ) + return self.process_alert(alert_event, cluster_sender=cluster_sender) + return [] + + def process_alert(self, alert_event: forta_agent.alert_event.AlertEvent, cluster_sender=CLUSTER_SENDER): + findings = [] + finding = None + description = alert_event.alert.description + if "created contract" in description: + description_split = description.split(" ") + creator_address = description_split[0] + contract_address = description_split[-1] + self.add_address(contract_address) + self.add_address(creator_address) + self.add_directed_edge(web3, creator_address, contract_address) + self.add_directed_edge(web3, contract_address, creator_address) #fake bidirectionnal edge to prevent filtering + finding = self.create_finding( + creator_address, "Triggered by high risk contract creation" + ) + if finding is not None: + findings.append(finding) + finding = None + + if cluster_sender: + transaction = web3.eth.get_transaction( + alert_event.transaction_hash + ) + sender_address = transaction["from"] + if str.lower(sender_address) != str.lower(creator_address): + self.add_address(sender_address) + self.add_directed_edge(web3, sender_address, creator_address) + self.add_directed_edge(web3, creator_address, sender_address) + finding = self.create_finding( + sender_address, "Triggered by high risk contract creation, transaction sender" + ) + if finding is not None: + findings.append(finding) + + + return findings + + +entity_cluster_agent = EntityClusterAgent( + DynamoPersistance(PROD_TAG, web3.eth.chain_id), TX_SAVE_STEP, web3.eth.chain_id +) + + +def handle_transaction( + transaction_event: forta_agent.transaction_event.TransactionEvent, +) -> list: return entity_cluster_agent.real_handle_transaction(transaction_event) + + +def handle_alert(alert_event: forta_agent.alert_event.AlertEvent): + logging.debug("handle_alert called") + + return entity_cluster_agent.provide_handle_alert(alert_event) diff --git a/entity-cluster-bot/src/agent_test.py b/entity-cluster-bot/src/agent_test.py index ae8fe5bf..80e4992f 100644 --- a/entity-cluster-bot/src/agent_test.py +++ b/entity-cluster-bot/src/agent_test.py @@ -1,12 +1,31 @@ +import sys from agent import EntityClusterAgent -from forta_agent import create_transaction_event +from forta_agent import create_transaction_event, create_alert_event, AlertEvent from datetime import datetime, timedelta import time import networkx as nx from web3 import Web3 -from web3_mock import CONTRACT, EOA_ADDRESS_LARGE_TX, EOA_ADDRESS_NEW, EOA_ADDRESS_OLD, EOA_ADDRESS_SMALL_TX, EOA_ADDRESS_FUNDED_NEW, EOA_ADDRESS_FUNDED_OLD, EOA_ADDRESS_FUNDER_NEW, EOA_ADDRESS_FUNDER_OLD, Web3Mock -from constants import ALERTED_ADDRESSES_KEY, GRAPH_KEY +from web3_mock import ( + CONTRACT, + EOA_ADDRESS_LARGE_TX, + EOA_ADDRESS_NEW, + EOA_ADDRESS_OLD, + EOA_ADDRESS_SMALL_TX, + EOA_ADDRESS_FUNDED_NEW, + EOA_ADDRESS_FUNDED_OLD, + EOA_ADDRESS_FUNDER_NEW, + EOA_ADDRESS_FUNDER_OLD, + Web3Mock, +) +from constants import ( + ALERTED_ADDRESSES_KEY, + GRAPH_KEY, + MALICIOUS_SMART_CONTRACT_BOT_ID, + SEVERITY_ALERT_FILTER, + CLUSTER_SENDER, +) + from forta_agent import get_alerts, get_json_rpc_url import timeit @@ -648,4 +667,194 @@ def test_sharding_finding_bidirectional(self): agent2 = EntityClusterAgent(DynamoPersistance()) findings = agent2.cluster_entities(real_w3, native_transfer2) - assert len(findings) == 1, "Finding should be returned as it is bidirectional" \ No newline at end of file + assert len(findings) == 1, "Finding should be returned as it is bidirectional" + + + def test_alert_suspicious_contract_creation(self): + TestEntityClusterBot.remove_persistent_state() + agent = EntityClusterAgent(DynamoPersistance()) + # https://app.forta.network/alerts/0xfac79700fcf706bee8452e699ad036c1de32c00b8e578f82181ecbc55c796ac6 + address_creator = "0x5df8c7c0725cdb6268f4503de880c38c45f69c61" + contract = "0x5be8df99c1b0b3e5512e29c6e1dd018f35758942" + description = f"{address_creator} created contract {contract}" + metadata = {} + high_alert = generate_alert( + bot_id=MALICIOUS_SMART_CONTRACT_BOT_ID, + alert_id="", + description=description, + metadata=metadata, + severity="HIGH", + ) + findings = agent.provide_handle_alert(high_alert, cluster_sender=False) + assert ( + len(findings) == 1 + ), "Finding should be returned as it is a contract creation with high severity" + + def test_alert_suspicious_contract_creation_low_severity(self): + TestEntityClusterBot.remove_persistent_state() + agent = EntityClusterAgent(DynamoPersistance()) + address_creator = "0x5df8c7c0725cdb6268f4503de880c38c45f69c61" + contract = "0x5be8df99c1b0b3e5512e29c6e1dd018f35758942" + description = f"{address_creator} created contract {contract}" + metadata = {} + low_alert = generate_alert( + bot_id=MALICIOUS_SMART_CONTRACT_BOT_ID, + alert_id="", + description=description, + metadata=metadata, + severity="LOW", + ) + findings = agent.provide_handle_alert(low_alert, cluster_sender=False) + assert ( + len(findings) == 0 + ), "Finding should not be returned as it is a contract creation with low severity" + + def test_alert_suspicious_wrong_chain_id(self): + TestEntityClusterBot.remove_persistent_state() + agent = EntityClusterAgent(DynamoPersistance()) + address_creator = "0x5df8c7c0725cdb6268f4503de880c38c45f69c61" + contract = "0x5be8df99c1b0b3e5512e29c6e1dd018f35758942" + description = f"{address_creator} created contract {contract}" + metadata = {} + alert = generate_alert( + bot_id=MALICIOUS_SMART_CONTRACT_BOT_ID, + alert_id="", + description=description, + metadata=metadata, + severity="HIGH", + chain_id=56, + ) + findings = agent.provide_handle_alert(alert, cluster_sender=False) + assert ( + len(findings) == 0 + ), "Finding should not be returned as it is a contract creation with wrong chain id" + + def test_alert_suspicious_wrong_bot_id(self): + TestEntityClusterBot.remove_persistent_state() + agent = EntityClusterAgent(DynamoPersistance()) + address_creator = "0x5df8c7c0725cdb6268f4503de880c38c45f69c61" + contract = "0x5be8df99c1b0b3e5512e29c6e1dd018f35758942" + description = f"{address_creator} created contract {contract}" + metadata = {} + alert = generate_alert( + bot_id="0xfakebotid", + alert_id="", + description=description, + metadata=metadata, + severity="HIGH", + ) + findings = agent.provide_handle_alert(alert, cluster_sender=False) + assert ( + len(findings) == 0 + ), "Finding should not be returned as it is a contract creation with wrong bot id" + + def test_alert_suspicious_contract_missing_description(self): + TestEntityClusterBot.remove_persistent_state() + agent = EntityClusterAgent(DynamoPersistance()) + address_creator = "0x5df8c7c0725cdb6268f4503de880c38c45f69c61" + contract = "0x5be8df99c1b0b3e5512e29c6e1dd018f35758942" + metadata = {} + alert = generate_alert( + bot_id=MALICIOUS_SMART_CONTRACT_BOT_ID, + alert_id="", + description="", + metadata=metadata, + severity="HIGH", + ) + findings = agent.provide_handle_alert(alert, cluster_sender=False) + assert ( + len(findings) == 0 + ), "Finding should not be returned as it is a contract creation with missing description" + + def test_alert_suspicious_contract_creation_and_different_sender(self): + TestEntityClusterBot.remove_persistent_state() + agent = EntityClusterAgent(DynamoPersistance()) + address_sender = "0xaaaf0666a916bdf97710a8e44e42ba250490e5b8" + address_creator = "0x5df8c7c0725cdb6268f4503de880c38c45f69c61" + contract = "0x5be8df99c1b0b3e5512e29c6e1dd018f35758942" + description = f"{address_creator} created contract {contract}" + metadata = {} + alert = generate_alert( + bot_id=MALICIOUS_SMART_CONTRACT_BOT_ID, + alert_id="", + description=description, + metadata=metadata, + severity="HIGH", + transaction_hash="0x7b5c7f34821b8782a04c6e8f7bfe25115fa1fb6360c2e93ccc0d6e84655445aa", + ) + findings = agent.provide_handle_alert(alert, cluster_sender=True) + assert ( + len(findings) == 2 + ), "Finding should be returned as it is a contract creation with high severity" + + def test_alert_suspicious_contract_creation_and_same_sender(self): + TestEntityClusterBot.remove_persistent_state() + agent = EntityClusterAgent(DynamoPersistance()) + address_sender = "0xaaaf0666a916bdf97710a8e44e42ba250490e5b8" + address_creator = "0xaaaf0666a916bdf97710a8e44e42ba250490e5b8" + contract = "0x5be8df99c1b0b3e5512e29c6e1dd018f35758942" + description = f"{address_creator} created contract {contract}" + metadata = {} + alert = generate_alert( + bot_id=MALICIOUS_SMART_CONTRACT_BOT_ID, + alert_id="", + description=description, + metadata=metadata, + severity="HIGH", + transaction_hash="0x7b5c7f34821b8782a04c6e8f7bfe25115fa1fb6360c2e93ccc0d6e84655445aa", + ) + findings = agent.provide_handle_alert(alert, cluster_sender=True) + assert ( + len(findings) == 1 + ), "1 Finding should be returned as it is a contract creation with high severity and sender = contract creator" + + +def generate_alert( + bot_id: str, + alert_id: str, + description: str, + severity: str, + metadata: dict = {}, + addresses: list = [], + contracts: list = [], + transaction_hash: str = "0xabc", + labels: list = [], + chain_id: int = 1, +) -> AlertEvent: + + alert_event = { + "alertId": alert_id, + "chainId": chain_id, + "addresses": addresses, + "labels": labels, + "contracts": contracts, + "createdAt": "", + "description": description, + "name": "test_alert_name", + "protocol": "", + "scan_node_count": 0, + "hash": "", + "source": { + "transactionHash": transaction_hash, + "block": {"timestamp": "", "chainId": chain_id, "hash": "", "number": 0}, + "bot": { + "id": bot_id, + "reference": "", + "image": "", + "sourceAlert": { + "hash": "", + "botId": bot_id, + "timestamp": "", + "chainId": chain_id, + }, + }, + }, + "projects": [], + "contacts": [], + "findingType": "Unknown", + "severity": severity, + "metadata": metadata, + } + + alert = {"alert": alert_event} + return create_alert_event(alert) diff --git a/entity-cluster-bot/src/constants.py b/entity-cluster-bot/src/constants.py index f156b0cb..ffaba72e 100644 --- a/entity-cluster-bot/src/constants.py +++ b/entity-cluster-bot/src/constants.py @@ -28,5 +28,10 @@ # Timeout for w3 calls in seconds HTTP_RPC_TIMEOUT = 2 # timeout of the lock in the mutex db 10s -MUTEX_TIMEOUT_MILLIS=10*10000 +MUTEX_TIMEOUT_MILLIS = 10 * 10000 +MALICIOUS_SMART_CONTRACT_BOT_ID = ( + "0x9aaa5cd64000e8ba4fa2718a467b90055b70815d60351914cc1cbe89fe1c404c" +) +SEVERITY_ALERT_FILTER = "HIGH" +CLUSTER_SENDER = True