diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7fb6cbf1f..76190a11b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: push: branches: - "T4627_py3_main" - - "T5282_add_mailgun" + - "T5281_port_cc_token" jobs: tests: diff --git a/canarytokens/canarydrop.py b/canarytokens/canarydrop.py index 87cdcd7ae..117e232cf 100644 --- a/canarytokens/canarydrop.py +++ b/canarytokens/canarydrop.py @@ -16,7 +16,7 @@ from datetime import datetime, timedelta from hashlib import md5 from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Literal, Optional, Union from pydantic import BaseModel, EmailStr, Field, HttpUrl, parse_obj_as, root_validator @@ -118,6 +118,17 @@ class Canarydrop(BaseModel): wg_key: Optional[str] # cmd specific stuff cmd_process: Optional[str] + # CC specific stuff + cc_id: Optional[str] + cc_kind: Optional[str] + cc_number: Optional[str] + cc_cvc: Optional[str] + cc_expiration: Optional[str] + cc_name: Optional[str] + cc_billing_zip: Optional[str] + cc_address: Optional[str] + cc_rendered_html: Optional[str] + cc_rendered_csv: Optional[str] @root_validator(pre=True) def _validate_triggered_details(cls, values): @@ -166,7 +177,7 @@ class Config: def add_additional_info_to_hit( self, hit_time: str, - additional_info: Dict[str, str], + additional_info: dict[str, str], ) -> None: """ """ trigger_details = queries.get_canarydrop_triggered_details(self.canarytoken) @@ -232,11 +243,15 @@ def get_url_components( queries.get_all_canary_pages(), ) - def generate_random_url(self, canary_domains: List[str]): + def generate_random_url(self, canary_domains: list[str]): """ Return a URL generated at random with the saved Canarytoken. The random URL is also saved into the Canarydrop. """ + # TODO: check how we want this caching to work. Use a property if needed. + # Or @lru.cache() or as it was but it's non-obvious + if self.generated_url: + return self.generated_url (path_elements, pages) = self.get_url_components() generated_url = random.choice(canary_domains) + "/" @@ -252,13 +267,11 @@ def generate_random_url(self, canary_domains: List[str]): path.append(pages[random.randint(0, len(pages) - 1)]) generated_url += "/".join(path) - # TODO: check how we want this caching to work. Use a property if needed. - # Or @lru.cache() or as it was but it's non-obvious - # self.generated_url = generated_url - # self.generated_url + # cache + self.generated_url = generated_url return generated_url - def get_url(self, canary_domains: List[str]): + def get_url(self, canary_domains: list[str]): return self.generate_random_url(canary_domains) def generate_random_hostname(self, with_random=False, nxdomain=False): @@ -281,7 +294,6 @@ def generate_random_hostname(self, with_random=False, nxdomain=False): return generated_hostname def get_hostname(self, with_random=False, as_url=False, nxdomain=False): - random_hostname = self.generate_random_hostname( with_random=with_random, nxdomain=nxdomain, @@ -344,7 +356,7 @@ def get_requested_output_channels( ): """Return a list containing the output channels configured in this Canarydrop.""" - channels: List[str] = [] + channels: list[str] = [] if self.alert_email_enabled and self.alert_email_recipient: channels.append(OUTPUT_CHANNEL_EMAIL) if self.alert_webhook_enabled and self.alert_webhook_url: @@ -449,12 +461,12 @@ def get_csv_incident_list(self) -> str: return csvOutput.getvalue() - def format_triggered_details_of_history_page(self) -> Dict[str, Any]: + def format_triggered_details_of_history_page(self) -> dict[str, Any]: """ Helper function as history.html still relies on v2 format. TODO: remove this when history.html is updated. Returns: - Dict[str, Any]: v2 formatted incident list. + dict[str, Any]: v2 formatted incident list. """ return self.triggered_details.serialize_for_v2(readable_time_format=True) diff --git a/canarytokens/channel.py b/canarytokens/channel.py index 24d209c63..dc2ee9ea1 100644 --- a/canarytokens/channel.py +++ b/canarytokens/channel.py @@ -173,7 +173,6 @@ def format_email_canaryalert( protocol: str, host: str, # DESIGN: Shift this to settings. Do we need to have this logic here? ) -> TokenAlertDetails: - details = cls.gather_alert_details( canarydrop, protocol=protocol, @@ -329,7 +328,6 @@ def send_alert( canarydrop: Canarydrop, token_hit: AnyTokenHit, ) -> None: - self.do_send_alert( input_channel=input_channel, canarydrop=canarydrop, diff --git a/canarytokens/channel_http.py b/canarytokens/channel_http.py index 30beb3ea5..3c0700fa5 100644 --- a/canarytokens/channel_http.py +++ b/canarytokens/channel_http.py @@ -8,7 +8,7 @@ # from canarytokens.channel_dns import create_token_hit from twisted.web import resource, server from twisted.web.resource import EncodingResourceWrapper, Resource -from twisted.web.server import GzipEncoderFactory +from twisted.web.server import GzipEncoderFactory, Request from canarytokens import queries from canarytokens.channel import InputChannel @@ -43,7 +43,7 @@ def getChild(self, name, request): return self return Resource.getChild(self, name, request) - def render_GET(self, request): + def render_GET(self, request: Request): # A GET request to a token URL can trigger one of a few responses: # 1. Check if link has been clicked on (rather than loaded from an # ) by looking at the Accept header, then: @@ -54,7 +54,18 @@ def render_GET(self, request): # 2b. Serve our default 1x1 gif try: - canarytoken = Canarytoken(value=request.path) + manage_uris = [ + b"/generate", + b"/download?", + b"/history?", + b"/manage?", + b"/resources/", + b"/settings", + ] + if any([request.path.find(x) >= 0 for x in manage_uris]): + canarytoken = Canarytoken(value=request.path) + else: + canarytoken = Canarytoken(value=request.uri) except NoCanarytokenFound as e: log.info( f"HTTP GET on path {request.path} did not correspond to a token. Error: {e}" @@ -98,7 +109,7 @@ def render_GET(self, request): request.setHeader("Server", "Apache") return resp - def render_POST(self, request): + def render_POST(self, request: Request): try: token = Canarytoken(value=request.path) except NoCanarytokenFound as e: diff --git a/canarytokens/channel_input_smtp.py b/canarytokens/channel_input_smtp.py index f3fdfad20..452b4f080 100644 --- a/canarytokens/channel_input_smtp.py +++ b/canarytokens/channel_input_smtp.py @@ -137,7 +137,6 @@ def __init__(self, **kwargs): def greeting( self, ): - self.src_ip = self.transport.getPeer().host try: return self.factory.responses["greeting"] diff --git a/canarytokens/channel_output_email.py b/canarytokens/channel_output_email.py index 4ddfe9400..d9422fa76 100644 --- a/canarytokens/channel_output_email.py +++ b/canarytokens/channel_output_email.py @@ -225,7 +225,6 @@ def sendgrid_send( email_subject: str, sandbox_mode: bool = False, ) -> tuple[bool, str]: - sendgrid_client = sendgrid.SendGridAPIClient( api_key=api_key.get_secret_value().strip() ) diff --git a/canarytokens/channel_output_webhook.py b/canarytokens/channel_output_webhook.py index 38f066cca..872290a52 100644 --- a/canarytokens/channel_output_webhook.py +++ b/canarytokens/channel_output_webhook.py @@ -25,7 +25,6 @@ def do_send_alert( canarydrop: canarydrop.Canarydrop, token_hit: AnyTokenHit, ) -> None: - payload = input_channel.format_webhook_canaryalert( canarydrop=canarydrop, host=self.frontend_hostname, @@ -42,7 +41,6 @@ def generic_webhook_send( payload: Dict[str, str], alert_webhook_url: HttpUrl, ) -> None: - # Design: wrap in a retry? try: response = requests.post( diff --git a/canarytokens/datagen.py b/canarytokens/datagen.py new file mode 100644 index 000000000..f74e29802 --- /dev/null +++ b/canarytokens/datagen.py @@ -0,0 +1,14 @@ +from faker import Faker + +fake = Faker() + + +def generate_person() -> dict[str, str]: + address = fake.address() + billing_zip = address.split(" ")[-1] + return { + "first_name": fake.first_name(), + "last_name": fake.last_name(), + "address": address, + "billing_zip": billing_zip, + } diff --git a/canarytokens/extendtoken.py b/canarytokens/extendtoken.py new file mode 100644 index 000000000..c3d7f5716 --- /dev/null +++ b/canarytokens/extendtoken.py @@ -0,0 +1,381 @@ +import datetime +import json +import os +from typing import Optional + +import requests + +from canarytokens.datagen import generate_person +from canarytokens.models import ApiProvider, CreditCard + + +class ExtendAPIException(Exception): + pass + + +class ExtendAPIRateLimitException(Exception): + pass + + +class ExtendAPICardsException(Exception): + pass + + +class ExtendAPI(ApiProvider): + """Class for interacting with the Extend API for virtual card management""" + + def __init__( + self, + email, + password, + card_name, + token=None, + ): + self.email = email + self.token = token + self.kind = "AMEX" + self.card_name = card_name + if self.token: + return + + req = self._post_api( + "https://api.paywithextend.com/signin", + {"email": self.email, "password": password}, + ) + self.token = req.json().get("token") + self.refresh_token = req.json().get("refresh_token") + + def _post_api(self, endpoint: str, data: Optional[str] = None) -> requests.Response: + """Performs a POST against the passed endpoint with the data passed""" + headers = { + "Content-Type": "application/json", + "Accept": "application/vnd.paywithextend.v2021-03-12+json", + } + if self.token is not None: + headers["Authorization"] = "Bearer {}".format(self.token) + resp = requests.post(endpoint, json=data, headers=headers) + if resp.status_code == 422: + raise ExtendAPIRateLimitException( + "ExtendAPI call failed with 422 rate limit." + ) + print(resp.content) + try: + json_error, text_error = resp.json().get("error", ""), "" + except requests.exceptions.JSONDecodeError: + json_error, text_error = "", resp.text + if resp.status_code != 200 or json_error: + raise ExtendAPIException( + "ExtendAPI call failed. Response code {}, error={}".format( + resp.status_code, repr(json_error + text_error) + ) + ) + return resp + + def _get_api( + self, endpoint: str, data: Optional[str] = None + ) -> requests.Response: # pragma: no cover + """Performs a GET against the passed endpoint""" + headers = { + "Content-Type": "application/json", + "Accept": "application/vnd.paywithextend.v2021-03-12+json", + } + if self.token is not None: + headers["Authorization"] = "Bearer {}".format(self.token) + resp = requests.get(endpoint, headers=headers, json=data) + if resp.status_code != 200 or resp.json().get("error", "") != "": + raise ExtendAPIException( + "ExtendAPI call failed. Response code {}, error={}".format( + resp.status_code, resp.json().get("error") + ) + ) + return resp + + def _put_api( + self, endpoint: str, data: Optional[str] = None + ) -> requests.Response: # pragma: no cover + """Performs a PUT against the passed endpoint""" + headers = { + "Content-Type": "application/json", + "Accept": "application/vnd.paywithextend.v2021-03-12+json", + } + if self.token is not None: + headers["Authorization"] = f"Bearer {self.token}" + resp = requests.put(endpoint, headers=headers, json=data) + if resp.status_code != 200 or resp.json().get("error", "") != "": + raise ExtendAPIException( + "ExtendAPI call failed. Response code {}, error={}".format( + resp.status_code, resp.json().get("error") + ) + ) + return resp + + def _delete_api(self, endpoint: str): # pragma: no cover + """Performs a DELETE against the passed endpoint""" + headers = { + "Content-Type": "application/json", + "Accept": "application/vnd.paywithextend.v2021-03-12+json", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + resp = requests.delete(endpoint, headers=headers) + if resp.status_code != 200 or resp.json().get("error", "") != "": + raise ExtendAPIException( + "ExtendAPI call failed. Response code {}, error={}".format( + resp.status_code, resp.json().get("error") + ) + ) + return resp + + def _refresh_auth_token(self): # pragma: no cover + """Refreshes the auth session token""" + req = self._post_api( + "https://api.paywithextend.com/renewauth", + {"refreshToken": self.refresh_token}, + ) + self.token = req.json().get("token") + self.refresh_token = req.json().get("refresh_token") + + @classmethod + def fetch_credentials(cls, path=None): # pragma: no cover + if not path: + raise Exception("No path supplied") + + if not os.path.exists(path): + raise Exception(f"File does not exist: {path}") + + with open(path) as f: + credentials = json.loads(f.read().strip()) + + return credentials["EXTEND_EMAIL_ADDRESS"], credentials["EXTEND_API_KEY"] + + def get_virtual_cards(self) -> list[tuple[str, str]]: # pragma: no cover + """Returns a list of tuples of (card owner, card id)""" + req = self._get_api( + "https://api.paywithextend.com/virtualcards?count=50&page=0" + ) + cards = [] + for vc in req.json().get("virtualCards", []): + cards.append( + ( + vc["recipient"]["firstName"] + " " + vc["recipient"]["lastName"], + vc.get("id"), + ) + ) + return cards + + def get_card_info(self, card_id) -> Optional[dict[str, str]]: + """Returns all the data about a passed card_id available""" + req = self._get_api("https://v.paywithextend.com/virtualcards/" + card_id) + return req.json() + + def get_transaction( + self, txn_id: str + ) -> Optional[dict[str, str]]: # pragma: no cover + """Returns more details about a specific transaction""" + req = self._get_api("https://api.paywithextend.com/transactions/" + txn_id) + return req.json() + + def get_card_transactions(self, card_id) -> list[dict]: # pragma: no cover + """Gets all the recent card transactions for a given card_id""" + req = self._get_api( + "https://api.paywithextend.com/virtualcards/{0}/transactions?status=DECLINED,PENDING,CLEARED".format( + card_id + ) + ) + return req.json().get("transactions", []) + + def get_latest_transaction( + self, cc: CreditCard + ) -> Optional[dict[str, str]]: # pragma: no cover + """Gets the latest transaction for a given credit card""" + txns = self.get_card_transactions(cc.id) + if len(txns) == 0: + return None + max_tx = txns[0] + max_dt = datetime.datetime.fromisoformat(max_tx["authedAt"].split("+")[0]) + for txn in txns: + dt = datetime.datetime.fromisoformat(txn["authedAt"].split("+")[0]) + if dt > max_dt: + max_dt = dt + max_tx = txn + return {max_dt.toisoformat(): max_tx} + + def get_parent_card_id(self) -> str: # pragma: no cover + """Gets the ID of the organization's real CC""" + resp = self._get_api("https://api.paywithextend.com/creditcards") + cards = resp.json().get("creditCards") + # import rpdb; rpdb.set_trace() + if len(cards) == 0: + raise ExtendAPICardsException("No cards returned from Extend") + + filtered_cards = [x for x in cards if x["displayName"] == self.card_name] + + if len(filtered_cards) == 0: + raise ExtendAPICardsException("No card is called {}".format(self.card_name)) + + if len(filtered_cards) > 1: + raise ExtendAPICardsException( + "Multiple cards are called {}".format(self.card_name) + ) + + return filtered_cards[0]["id"] + + def make_card( + self, + token_url: str, + first_name: str, + last_name: str, + address: str, + billing_zip: str, + limit_cents: int = 100, + ) -> CreditCard: + """Creates a new CreditCard via Extend's CreateVirtualCard API""" + cc = self.get_parent_card_id() + now_ts = datetime.datetime.now() - datetime.timedelta(days=1) + now_ts = now_ts.isoformat() + "+0000" + expiry = datetime.datetime.now() + datetime.timedelta(weeks=2 * 52) + future_ts = expiry.isoformat() + "+0000" + expiry_str = str(expiry.month) + "/" + str(expiry.year) + notes = token_url + data = { + "creditCardId": cc, + "recipient": self.email, + "displayName": first_name + " " + last_name + "'s card", + "balanceCents": limit_cents, + "direct": "false", + "recurs": "false", + "validFrom": now_ts, + "validTo": future_ts, + "notes": notes, + "referenceFields": [], + "validMccRanges": [{"lowest": "9403", "highest": "9403"}], + } + out = CreditCard( + id="", + name=first_name + " " + last_name, + number=None, + cvc=None, + billing_zip=billing_zip, + expiration=expiry_str, + address=address, + kind=self.kind, + ) + req = self._post_api("https://api.paywithextend.com/virtualcards", data=data) + out.id = req.json().get("virtualCard")["id"] + vc_info = self.get_card_info(out.id) + if "vcn" in vc_info["virtualCard"].keys(): + out.cvc = vc_info["virtualCard"]["securityCode"] + out.number = vc_info["virtualCard"]["vcn"] + else: + print("ERROR GETTING CARD DETAILS") + return out + + def cancel_card(self, card_id) -> None: # pragma: no cover + """Cancels a passed card""" + _ = self._put_api( + "https://api.paywithextend.com/virtualcards/" + card_id + "/cancel" + ) + + def create_credit_card( + self, + token_url: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + address: Optional[str] = None, + billing_zip: Optional[str] = None, + ) -> CreditCard: + """Creates a cardholder and associated virtual card for the passed person, if not passed, will generate fake data to use""" + fake_person = generate_person() + if first_name is None: + first_name = fake_person["first_name"] + if last_name is None: + last_name = fake_person["last_name"] + if address is None: + address = fake_person["address"] + if billing_zip is None: + billing_zip = fake_person["billing_zip"] + + cc = self.make_card( + token_url=token_url, + first_name=first_name, + last_name=last_name, + address=address, + billing_zip=billing_zip, + ) + + return cc + + def get_credit_card(self, id: str) -> CreditCard: + """Abstract method to get a virtual credit card""" + pass + + def get_transaction_events( # noqa C901 # pragma: no cover + self, since: Optional[datetime.datetime] = None + ) -> list: + """Returns a list of recent transactions for the org""" + txns = [] + req = self._get_api("https://api.paywithextend.com/events") + for event in req.json().get("events"): + if since is not None: + if since > datetime.datetime.fromisoformat( + event["timestamp"].split("+")[0] + ): + # We've gone far enough back to not need to continue + return txns + if "transaction" in event["type"]: + txns.append(event["data"]) + if req.json().get("pagination")["numberOfPages"] > 1: + page = 1 + while page < req.json().get("pagination")["numberOfPages"]: + req = self._get_api( + "https://api.paywithextend.com/events?page={0}".format(str(page)) + ) + for event in req.json().get("events"): + if since is not None: + if since > datetime.datetime.fromisoformat( + event["timestamp"].split("+")[0] + ): + # We've gone far enough back to not need to continue + return txns + if "transaction" in event["type"]: + txns.append(event["data"]) + page += 1 + return txns + + def subscribe_to_txns(self, url: str): # pragma: no cover + """Adds a subscription to send transaction events to the passed webhook url""" + parent_cc = self.get_parent_card_id() + events = [ + "transaction.authorized", + "transaction.declined", + "transaction.reversed", + "transaction.settled", + "transaction.updated.no_match", + ] + body = {"creditCardId": parent_cc, "enabledEvents": events, "url": url} + req = self._post_api("https://api.paywithextend.com/subscriptions", body) + return req.json() + + def delete_subscription(self, sub_id): # pragma: no cover + self._delete_api("https://api.paywithextend.com/subscriptions/" + sub_id) + + def get_transaction_info_from_event(self, eventid: str): # pragma: no cover + """Returns the virtual card ID from a transaction event""" + res = self._get_api("https://api.paywithextend.com/events/" + eventid) + return res.json().get("event", {}).get("data", {}) + + def issue_test_transaction( + self, cc: CreditCard, amount: int = 1000 + ) -> None: # pragma: no cover + """Issues a test transaction to the passed card""" + return None # This API does not work for "real" cards + data = {"amount": amount, "type": "DECLINED"} + req = self._post_api( + "https://api.paywithextend.com/virtualcards/{0}/transactions/simulate".format( + cc.id + ), + data=data, + ) + if req.status_code != 200: + print("Error issuing test transaction to: " + cc.id) + return req diff --git a/canarytokens/models.py b/canarytokens/models.py index 289ec178c..19ae5e923 100644 --- a/canarytokens/models.py +++ b/canarytokens/models.py @@ -1,4 +1,6 @@ from __future__ import annotations +from abc import ABCMeta, abstractmethod +import csv import enum import json @@ -8,8 +10,9 @@ from dataclasses import dataclass from datetime import datetime from distutils.util import strtobool +from fastapi.responses import JSONResponse from functools import cached_property -from io import BytesIO +from io import BytesIO, StringIO from ipaddress import IPv4Address from tempfile import SpooledTemporaryFile from typing import ( @@ -50,6 +53,19 @@ re.IGNORECASE, ) +response_error = lambda error, message: JSONResponse( # noqa: E731 # lambda is cleaner + { + "error": str(error), + "error_message": message, + "url": "", + "url_components": None, + "token": "", + "email": "", + "hostname": "", + "auth": "", + } +) + class Memo(ConstrainedStr): max_length: int = MEMO_MAX_CHARACTERS @@ -153,6 +169,99 @@ class KubeCerts(TypedDict): k: bytes # Key +class CreditCard(BaseModel): + id: str + number: Optional[str] + cvc: Optional[str] + expiration: Optional[str] + kind: Optional[str] + name: str + billing_zip: str + address: str + + def render_html(self) -> str: + """Returns an HTML div to render the card info on a website""" + # return """
{name}{number}{expiration}{cvc}
""".format( + # kind=self.kind, + # cvc=self.cvc, + # number=self.number, + # name=self.name, + # expiration=self.expiration, + # ) + return f"""
{self.name}{self.__format_token()}{self.expiration}{self.cvc}
""" + + def to_csv(self) -> str: + f = StringIO() + fn = ["name", "type", "number", "cvc", "exp", "billing_zip"] + sd = self.to_dict() + del sd["address"] + del sd["id"] + writer = csv.DictWriter(f, fieldnames=fn) + writer.writeheader() + writer.writerow(sd) + return f.getvalue() + + def to_dict(self) -> Dict[str, str]: + """Returns the CC information as a python dict""" + out = { + "id": str(self.id), + "name": self.name, + "number": str(self.number), + "cvc": str(self.cvc), + "billing_zip": str(self.billing_zip), + "type": str(self.kind), + "address": str(self.address), + "exp": str(self.expiration), + } + return out + + def __format_token(self): + digits = 4 + if self.kind != "AMEX": + split = [ + self.number[i : i + digits] # noqa: E203 + for i in range(0, len(self.number), digits) + ] + return " ".join(split) + else: + split = [self.number[0:4], self.number[4:10], self.number[10:15]] + return " ".join(split) + + +class ApiProvider(metaclass=ABCMeta): + """Abstract base class for a credit card API provider""" + + def __init__(self): + pass + + @abstractmethod + def create_credit_card( + self, + token_url: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + address: Optional[str] = None, + billing_zip: Optional[str] = None, + ) -> CreditCard: + """Abstract method to create a virtual credit card number""" + pass + + @abstractmethod + def get_credit_card(self, id: str) -> CreditCard: + """Abstract method to get a virtual credit card""" + pass + + @abstractmethod + def get_latest_transaction(self, cc: CreditCard) -> Optional[Dict[str, str]]: + """Abstract method to get the latest transaction for a credit card""" + pass + + +# class CCToken(object): +# def __init__(self, api_provider: ApiProvider): +# pass + + class TokenTypes(str, enum.Enum): """Enumerates all supported token types""" @@ -177,6 +286,7 @@ class TokenTypes(str, enum.Enum): KUBECONFIG = "kubeconfig" LOG4SHELL = "log4shell" CMD = "cmd" + CC = "cc" def __str__(self) -> str: return str(self.value) @@ -337,6 +447,10 @@ def check_process_name(value: str): return value +class CCTokenRequest(TokenRequest): + token_type: Literal[TokenTypes.CC] = TokenTypes.CC + + class KubeconfigTokenRequest(TokenRequest): token_type: Literal[TokenTypes.KUBECONFIG] = TokenTypes.KUBECONFIG @@ -479,6 +593,7 @@ class WindowsDirectoryTokenRequest(TokenRequest): AnyTokenRequest = Annotated[ Union[ + CCTokenRequest, CMDTokenRequest, FastRedirectTokenRequest, QRCodeTokenRequest, @@ -565,6 +680,17 @@ class CMDTokenResponse(TokenResponse): reg_file: str +class CCTokenResponse(TokenResponse): + token_type: Literal[TokenTypes.CC] = TokenTypes.CC + kind: str + number: str + cvc: str + expiration: str + name: str + billing_zip: str + rendered_html: str + + class QRCodeTokenResponse(TokenResponse): token_type: Literal[TokenTypes.QR_CODE] = TokenTypes.QR_CODE qrcode_png: str @@ -746,6 +872,7 @@ class MySQLTokenResponse(TokenResponse): AnyTokenResponse = Annotated[ Union[ + CCTokenResponse, CMDTokenResponse, CustomImageTokenResponse, SMTPTokenResponse, @@ -1090,6 +1217,13 @@ class PDFTokenHit(TokenHit): token_type: Literal[TokenTypes.ADOBE_PDF] = TokenTypes.ADOBE_PDF +class CCTokenHit(TokenHit): + token_type: Literal[TokenTypes.CC] = TokenTypes.CC + last4: str + amount: str + merchant: str + + class CMDTokenHit(TokenHit): token_type: Literal[TokenTypes.CMD] = TokenTypes.CMD @@ -1201,6 +1335,7 @@ class WireguardTokenHit(TokenHit): AnyTokenHit = Annotated[ Union[ + CCTokenHit, CMDTokenHit, DNSTokenHit, AWSKeyTokenHit, @@ -1318,6 +1453,11 @@ class PDFTokenHistory(TokenHistory[PDFTokenHit]): hits: List[PDFTokenHit] +class CCTokenHistory(TokenHistory[CCTokenHit]): + token_type: Literal[TokenTypes.CC] = TokenTypes.CC + hits: List[CCTokenHit] + + class CMDTokenHistory(TokenHistory[CMDTokenHit]): token_type: Literal[TokenTypes.CMD] = TokenTypes.CMD hits: List[CMDTokenHit] @@ -1456,6 +1596,7 @@ class SvnTokenHistory(TokenHistory[SvnTokenHit]): # TokenHistory where they differ only in `token_type`. AnyTokenHistory = Annotated[ Union[ + CCTokenHistory, CMDTokenHistory, DNSTokenHistory, AWSKeyTokenHistory, @@ -1679,6 +1820,7 @@ class DownloadFmtTypes(str, enum.Enum): MYSQL = "my_sql" QRCODE = "qr_code" CMD = "cmd" + CC = "cc" def __str__(self) -> str: return str(self.value) @@ -1749,6 +1891,10 @@ class DownloadCMDRequest(TokenDownloadRequest): fmt: Literal[DownloadFmtTypes.CMD] = DownloadFmtTypes.CMD +class DownloadCCRequest(TokenDownloadRequest): + fmt: Literal[DownloadFmtTypes.CC] = DownloadFmtTypes.CC + + class DownloadKubeconfigRequest(TokenDownloadRequest): fmt: Literal[DownloadFmtTypes.KUBECONFIG] = DownloadFmtTypes.KUBECONFIG @@ -1760,6 +1906,7 @@ class DownloadSplackApiRequest(TokenDownloadRequest): AnyDownloadRequest = Annotated[ Union[ DownloadAWSKeysRequest, + DownloadCCRequest, DownloadCMDRequest, DownloadIncidentListCSVRequest, DownloadIncidentListJsonRequest, @@ -1857,6 +2004,15 @@ class DownloadIncidentListCSVResponse(TokenDownloadResponse): auth: str +class DownloadCCResponse(TokenDownloadResponse): + contenttype: Literal[ + DownloadContentTypes.TEXTPLAIN + ] = DownloadContentTypes.TEXTPLAIN + filename: str + token: str + auth: str + + class DownloadCMDResponse(TokenDownloadResponse): contenttype: Literal[ DownloadContentTypes.TEXTPLAIN diff --git a/canarytokens/msword.py b/canarytokens/msword.py index 56ae91c09..95d34d3d0 100644 --- a/canarytokens/msword.py +++ b/canarytokens/msword.py @@ -14,7 +14,6 @@ def make_canary_msword(url: str, template: Path): - with open(template, "rb") as f: input_buf = BytesIO(f.read()) output_buf = BytesIO() diff --git a/canarytokens/pdfgen.py b/canarytokens/pdfgen.py index b64f05d6f..37a0dbd40 100644 --- a/canarytokens/pdfgen.py +++ b/canarytokens/pdfgen.py @@ -49,7 +49,6 @@ def _substitute_stream( def make_canary_pdf( hostname: bytes, template: Path, stream_offset: int = STREAM_OFFSET ): - with open(template, "rb") as fp: contents = fp.read() diff --git a/canarytokens/queries.py b/canarytokens/queries.py index 677384d9b..a461dcfab 100644 --- a/canarytokens/queries.py +++ b/canarytokens/queries.py @@ -45,7 +45,6 @@ def get_canarydrop(canarytoken: tokens.Canarytoken) -> Optional[cand.Canarydrop]: - canarydrop: dict = DB.get_db().hgetall(KEY_CANARYDROP + canarytoken.value()) if len(canarydrop) == 0: @@ -466,6 +465,7 @@ def get_return_for_token(): # return User(DB.get_db().hgetall(account_key)) + # TODO: add counter's / metrics so it's easy to consume. def lookup_canarytoken_alert_count(canarytoken: tokens.Canarytoken) -> int: key = KEY_CANARYTOKEN_ALERT_COUNT + canarytoken.value() @@ -564,9 +564,9 @@ def put_mail_on_sent_queue(mail_key: str, details: models.TokenAlertDetails) -> return DB.get_db().lpush(KEY_SENT_MAIL_QUEUE, sent_mail) -def pop_mail_off_sent_queue() -> tuple[ - Optional[str], Optional[models.TokenAlertDetails] -]: +def pop_mail_off_sent_queue() -> ( + tuple[Optional[str], Optional[models.TokenAlertDetails]] +): item = DB.get_db().rpop(KEY_SENT_MAIL_QUEUE, count=1) if item is None: log.info(f"No mail to send on queue: {KEY_SENT_MAIL_QUEUE}") @@ -918,7 +918,6 @@ def is_email_blocked(email): def is_tor_relay(ip): - if not DB.get_db().exists(KEY_TOR_EXIT_NODES): update_tor_exit_nodes_loop() # FIXME: DESIGN: we call defered and expect a result in redis, Now! return DB.get_db().sismember(KEY_TOR_EXIT_NODES, json.dumps(ip)) diff --git a/canarytokens/settings.py b/canarytokens/settings.py index f9474e777..e77f6b4ce 100644 --- a/canarytokens/settings.py +++ b/canarytokens/settings.py @@ -8,7 +8,6 @@ class Settings(BaseSettings): - CHANNEL_DNS_PORT: Port = Port(5354) CHANNEL_HTTP_PORT: Port = Port(8083) CHANNEL_SMTP_PORT: Port = Port(2500) @@ -63,7 +62,6 @@ class Config: class FrontendSettings(BaseSettings): - API_APP_TITLE: str = "Canarytokens" API_VERSION_STR: str = "v1" FRONTEND_HOSTNAME: str @@ -90,6 +88,9 @@ class FrontendSettings(BaseSettings): # 3rd party settings GOOGLE_API_KEY: str + EXTEND_EMAIL: Optional[str] + EXTEND_PASSWORD: Optional[SecretStr] = SecretStr("NoExtendPasswordFound") + EXTEND_CARD_NAME: Optional[str] class Config: allow_mutation = False diff --git a/canarytokens/tokens.py b/canarytokens/tokens.py index b43731e91..2f267e797 100644 --- a/canarytokens/tokens.py +++ b/canarytokens/tokens.py @@ -414,6 +414,24 @@ def _get_info_for_clonedsite(request): } return http_general_info, src_data + @staticmethod + def _get_info_for_cc(request): + http_general_info = Canarytoken._grab_http_general_info(request=request) + + last4 = request.getHeader("Last4") + amount = "$" + request.getHeader("Amount") + merchant = request.getHeader("Merchant") + + # TODO: check if we need to nerf geo_info, src_ip and is_tor_relay + # from http_general_info + src_data = {"last4": last4, "amount": amount, "merchant": merchant} + return http_general_info, src_data + + @staticmethod + def _get_response_for_cc(canarydrop: canarydrop.Canarydrop, request: Request): + request.setHeader("Content-Type", "image/gif") + return GIF + @staticmethod def _get_response_for_clonedsite( canarydrop: canarydrop.Canarydrop, request: Request @@ -512,7 +530,6 @@ def _get_info_for_web_image(request): def _get_response_for_web_image( canarydrop: canarydrop.Canarydrop, request: Request ): - if request.getHeader("Accept") and "text/html" in request.getHeader("Accept"): if canarydrop.browser_scanner_enabled: # set response mimetype diff --git a/frontend/app.py b/frontend/app.py index 2dfa834c1..8d45fc113 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -33,7 +33,7 @@ from sentry_sdk.integrations.redis import RedisIntegration import canarytokens -from canarytokens import kubeconfig, msreg, queries +from canarytokens import extendtoken, kubeconfig, msreg, queries from canarytokens import wireguard as wg from canarytokens.authenticode import make_canary_authenticode_binary from canarytokens.awskeys import get_aws_key @@ -46,6 +46,8 @@ AnyTokenResponse, AWSKeyTokenRequest, AWSKeyTokenResponse, + CCTokenRequest, + CCTokenResponse, ClonedWebTokenRequest, ClonedWebTokenResponse, CMDTokenRequest, @@ -58,6 +60,8 @@ DNSTokenResponse, DownloadAWSKeysRequest, DownloadAWSKeysResponse, + DownloadCCRequest, + DownloadCCResponse, DownloadCMDRequest, DownloadCMDResponse, DownloadIncidentListCSVRequest, @@ -96,6 +100,7 @@ PDFTokenResponse, QRCodeTokenRequest, QRCodeTokenResponse, + response_error, SettingsResponse, SlowRedirectTokenRequest, SlowRedirectTokenResponse, @@ -300,20 +305,6 @@ async def generate(request: Request) -> AnyTokenResponse: # noqa: C901 # gen i """ Whatt """ - response_error = ( - lambda error, message: JSONResponse( # noqa: E731 # lambda is cleaner - { - "error": str(error), - "error_message": message, - "url": "", - "url_components": None, - "token": "", - "email": "", - "hostname": "", - "auth": "", - } - ) - ) if request.headers.get("Content-Type", "application/json") == "application/json": token_request_data = await request.json() @@ -549,6 +540,19 @@ def _( ) +@create_download_response.register +def _( + download_request_details: DownloadCCRequest, canarydrop: Canarydrop +) -> DownloadCCResponse: + """""" + return DownloadCCResponse( + token=download_request_details.token, + auth=download_request_details.auth, + content=canarydrop.cc_rendered_csv, + filename=f"{canarydrop.canarytoken.value()}.csv", + ) + + @create_download_response.register def _( download_request_details: DownloadMSWordRequest, canarydrop: Canarydrop @@ -968,6 +972,54 @@ def _( ) +@create_response.register +def _(token_request_details: CCTokenRequest, canarydrop: Canarydrop) -> CCTokenResponse: + eapi = extendtoken.ExtendAPI( + email=frontend_settings.EXTEND_EMAIL, + password=frontend_settings.EXTEND_PASSWORD.get_secret_value(), + card_name=frontend_settings.EXTEND_CARD_NAME, + ) + try: + cc = eapi.create_credit_card(token_url=canarydrop.token_url) + except extendtoken.ExtendAPIRateLimitException: + return response_error( + 4, "Credit Card Rate-Limiting currently in place. Please try again later." + ) + + if not cc or not cc.number: + return response_error( + 4, "Failed to generate credit card. Please contact support@thinkst.com." + ) + canarydrop.cc_kind = cc.kind + canarydrop.cc_number = cc.number + canarydrop.cc_cvc = cc.cvc + canarydrop.cc_expiration = cc.expiration + canarydrop.cc_name = cc.name + canarydrop.cc_billing_zip = cc.billing_zip + canarydrop.cc_rendered_html = cc.render_html() + canarydrop.cc_rendered_csv = cc.to_csv() + queries.save_canarydrop(canarydrop=canarydrop) + + return CCTokenResponse( + email=canarydrop.alert_email_recipient or "", + webhook_url=canarydrop.alert_webhook_url + if canarydrop.alert_webhook_url + else "", + token=canarydrop.canarytoken.value(), + token_url=canarydrop.get_url([canary_http_channel]), + auth_token=canarydrop.auth, + hostname=canarydrop.get_hostname(), + url_components=list(canarydrop.get_url_components()), + kind=canarydrop.cc_kind, + number=canarydrop.cc_number, + cvc=canarydrop.cc_cvc, + expiration=canarydrop.cc_expiration, + name=canarydrop.cc_name, + billing_zip=canarydrop.cc_billing_zip, + rendered_html=canarydrop.cc_rendered_html, + ) + + @create_response.register def _(token_request_details: PDFTokenRequest, canarydrop: Canarydrop): return PDFTokenResponse( diff --git a/poetry.lock b/poetry.lock index d17d03fb1..9c3488d52 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. [[package]] name = "anyio" @@ -1026,6 +1026,21 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "faker" +version = "17.6.0" +description = "Faker is a Python package that generates fake data for you." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Faker-17.6.0-py3-none-any.whl", hash = "sha256:5aaa16fa9cfde7d117eef70b6b293a705021e57158f3fa6b44ed1b70202d2065"}, + {file = "Faker-17.6.0.tar.gz", hash = "sha256:51f37ff9df710159d6d736d0ba1c75e063430a8c806b91334d7794305b5a6114"}, +] + +[package.dependencies] +python-dateutil = ">=2.4" + [[package]] name = "fastapi" version = "0.79.1" @@ -2269,7 +2284,7 @@ pyside2 = ["qt5reactor[pyside2] (>=0.6.3)"] name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "dev" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -3057,9 +3072,9 @@ test = ["zope.i18nmessageid", "zope.testing", "zope.testrunner"] [extras] twisted = ["Twisted", "sentry-sdk"] -web = ["fastapi", "uvicorn", "sentry-sdk"] +web = ["fastapi", "sentry-sdk", "uvicorn"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "663d851a51ed0cc8777d5eb0c04440845ea0726bbece79ea477bff0ea1d58ef3" +content-hash = "d825d7d01691c6b04ca88b5eed88e58bd786e37f762e87bbf939e55e707300c0" diff --git a/pyproject.toml b/pyproject.toml index a24d965d7..61579d927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ fastapi = { version = "^0.79.0", optional = true } sentry-sdk = {extras = ["fastapi"], version = "^1.9.5", optional = true} uvicorn = { version = "^0.17.6", optional = true } Twisted = {version = "^22.4.0", optional = true} +faker = "^17.6.0" [tool.poetry.extras] web = ["fastapi", "uvicorn", "sentry-sdk"] diff --git a/templates/emails/notification.html b/templates/emails/notification.html index 6910cd5c2..84b600707 100644 --- a/templates/emails/notification.html +++ b/templates/emails/notification.html @@ -125,6 +125,24 @@

Basic Details:

{{ BasicDetails['Log4_shell_computer_name'] | e}} {% endif %} + {% if BasicDetails['CMDInformation'] %} + + Sensitive Command Information + {{ BasicDetails['CMDInformation'] | e}} + + {% endif %} + {% if BasicDetails['Merchant'] %} + + Authorizing merchant + {{ BasicDetails['Merchant'] | e}} + + {% endif %} + {% if BasicDetails['Amount'] %} + + Transaction amount + {{ BasicDetails['Amount'] | e}} + + {% endif %} {% if BasicDetails['CanaryIP'] or BasicDetails['CanaryName'] %} Canary diff --git a/templates/generate_new.html b/templates/generate_new.html index f80de23e3..714659fce 100644 --- a/templates/generate_new.html +++ b/templates/generate_new.html @@ -31,6 +31,20 @@ Copy to clipboard {%- endmacro %} + +{% macro textareacopydownload(name, download_fmt, value="")%} + +
+ + + Download text area + +
+{%- endmacro %} + @@ -59,7 +73,8 @@ - + + \ No newline at end of file diff --git a/templates/static/credit-card.png b/templates/static/credit-card.png new file mode 100644 index 000000000..ed5635457 Binary files /dev/null and b/templates/static/credit-card.png differ diff --git a/templates/static/download.svg b/templates/static/download.svg new file mode 100644 index 000000000..cfc21ae85 --- /dev/null +++ b/templates/static/download.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/templates/static/msreg.png b/templates/static/msreg.png new file mode 100644 index 000000000..7a8332994 Binary files /dev/null and b/templates/static/msreg.png differ diff --git a/templates/static/styles.css b/templates/static/styles.css index 11b4834e4..12ed27e3e 100644 --- a/templates/static/styles.css +++ b/templates/static/styles.css @@ -14,6 +14,12 @@ border-bottom: 2px solid #fff; padding: 1rem 0rem; } +.goodtick { + height: 100px; + margin: auto; + margin-top: 20px; + margin-bottom: 20px; +} .error-outline { outline: none; @@ -308,6 +314,22 @@ input.form-control:placeholder, textarea.form-control:placeholder { overflow: scroll; } +input#search-token { + width: 100%; + height: 45px; + border: none; + border-bottom: 1px solid #eceeef; + text-align: center; + color: #888888; +} +input#search-token:focus, input#search-token:focus-visible { + outline: #eceeef; +} + +input#search-token::placeholder { + color: #c7c7c7; +} + .explanation { font-size: smaller; color: #999; @@ -379,6 +401,7 @@ a.btn-success.btn { background-color: #38d47f; width: 100%; border-color: #38d47f; + color: white; } button.btn.btn-success:disabled { opacity: .85; @@ -423,6 +446,7 @@ a.btn-success.btn:hover, a.btn-success.btn:focus { .result-data { width: 80%; + min-height: 100px; border: none; font-family: monospace; padding: 5px 12px; @@ -500,7 +524,17 @@ a.btn-success.btn:hover, a.btn-success.btn:focus { background: white; border: none; outline: none; - height: 20px; + height: 22px; + width: 22px; + background-image: url(/resources/copy-inactive.svg); + background-size: 22px; + background-repeat: no-repeat; + background-position: center; +} + +.copy-card .btn-clipboard.hide-button:hover { + background-image: url(/resources/copy-hover.svg); + cursor: pointer; } #ccRender { @@ -678,6 +712,34 @@ a.btn-success.btn:hover, a.btn-success.btn:focus { margin-top: 2rem; } +.advice li { + list-style: none; + position: relative; + margin: 20px 0 20px 0; + font-size: 15px; + /* color: #666666; */ +} +.advice li::before { + content: ''; + background-image: url('/resources/idea.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + background-size: 2em; + height: 100%; + width: 30px; + position: absolute; + left: -40px; +} + +@media screen and (max-width: 600px) { + .advice li { + margin: 10px 0 10px 0; + font-size: 14px; + } + } + + .create-link { text-align: center; margin-bottom: 15px; @@ -747,13 +809,13 @@ a.refresh:hover{ .bootstrap-switch .bootstrap-switch-handle-off.bootstrap-switch-primary, .bootstrap-switch .bootstrap-switch-handle-on.bootstrap-switch-primary { color: #fff; - background-color: #5cb85c; - border-color: #5cb85c; + background-color: #38d47f; + border-color: #38d47f; } .jumbotron .btn.btn-clipboard { - background-color: #5cb85c; - border-color: #5cb85c; + background-color: #38d47f; + border-color: #38d47f; color: #fff; } @@ -848,6 +910,9 @@ a.icon-svn:before { a.icon-aws:before { background-image: url("/resources/aws.png"); } +a.icon-azureid:before { + background-image: url("/resources/azure-id.png"); +} a.icon-redirect:before { background-image: url("/resources/redirect.png"); } @@ -984,4 +1049,4 @@ a:focus.what-is-this, a:hover.what-is-this { a.what-is-this svg { width: 19px; -} \ No newline at end of file +} diff --git a/templates/static/styles.min.css b/templates/static/styles.min.css index 6f72ef8b4..51d68e3fa 100644 --- a/templates/static/styles.min.css +++ b/templates/static/styles.min.css @@ -1 +1 @@ - .logo {height: 32px;}.header a {color: #38d47f;}.hidden {display: none!important;}.step {border-bottom: 2px solid #fff;padding: 1rem 0rem;}.error-outline {outline: none;border-color: #1ecaed;box-shadow: 0 0 13px #ec6161;}.success-outline {outline: none;border-color: #1ecaed;box-shadow: 0 0 10px #38d47f;}a.fileupload-clear:hover {text-decoration: none;}.jumbotron {padding: 0;position: relative;}.create {z-index: 1;position: relative;padding: 2rem 1rem;}.create-hidden {transition:opacity 0.5s ease;display: none;}.history-header {text-align:center;padding: 2rem 1rem;margin-bottom: 2rem;background-color: #eceeef;border-radius: .3rem;}.details-header{padding:0.7rem;text-align: center;}.footer {text-align: center;}.footer .banner {display: block;max-width: 46rem;margin: auto;margin-bottom: 20px;height: 210px;background-image: url('/resources/thinkst-canary-banner-hi-res-2.png');background-repeat: no-repeat;background-position: center;background-size: contain;}.footer a {color: #38d47f;}@media screen and (max-width: 425px) {.footer {padding: 0;font-size: 14px;}.footer p {margin-bottom: 10px;}.footer .banner {background-image: url('/resources/thinkst-canary-banner-mobile-2.png');height: 300px;margin-top: 10px;}}@media screen and (max-width: 375px) {.footer .banner {height: 335px;}}.incident-size{margin :0px auto;display: none;}.incident-list{overflow: auto;position: relative;margin-bottom: 10px;margin-top: 10px;}.incident-item{width: 100%;transition:opacity 0.5s ease;background-color: aliceblue;color: darkslategrey;border-radius: .3rem;margin-bottom: 0.5rem;cursor: pointer;}.incident-item-details{display:none;padding:1rem;}.incident-item:hover{outline: none;border-color: #1ecaed;box-shadow: 0 0 10px green;}.header_row{font-weight:bold;}.incident-item:first-child{margin-top:0.5rem;}.success {width: 100%;z-index: 0;transition:opacity 0.5s ease;background-color: #eceeef;color: darkslategrey;padding-top: 1rem;padding-bottom: 2rem;border-radius: .3rem;display: none;}.success-visible {z-index: 2;display: block;}.results {padding-top: 1rem;}input.form-control, input.fileupload, textarea {margin-top: 10px;margin-bottom: 10px;text-align: center;}input.form-control::-webkit-input-placeholder, textarea.form-control::-webkit-input-placeholder {color: #bbb;}input.form-control:-moz-placeholder, textarea.form-control:-moz-placeholder{color: #bbb;}input.form-control::-moz-placeholder, textarea.form-control::-moz-placeholder {color: #bbb;}input.form-control:placeholder, textarea.form-control:placeholder {color: #bbb;}.form-control {border: 2px solid #dadada;border-radius: 7px;}.form-control:focus {outline: none;border-color: #dadada;box-shadow: 0 0 14px #4a4a4a;}.step:last-child {border-bottom: none;padding-bottom: 0rem;}.wrapper-dropdown {position: relative;margin: 0 auto;margin-bottom: 10px;padding: 12px 15px;background: #fff;border-radius: 5px;cursor: pointer;outline: none;transition: all 0.3s ease-out;}.wrapper-dropdown:after {content: "";width: 0;height: 0;position: absolute;top: 50%;right: 15px;margin-top: -3px;border-width: 6px 6px 0 6px;border-style: solid;border-color: #38d47f transparent;}.success-outline.wrapper-dropdown:after {border-color: green transparent;}.wrapper-dropdown .dropdown {position: absolute;top: 100%;left: 0;right: 0;background: #fff;border-radius: 0 0 5px 5px;border: 1px solid rgba(0,0,0,0.2);border-top: none;border-bottom: none;list-style: none;transition: all 0.3s ease-out;padding-left: 0px;max-height: 0;overflow: hidden;z-index: 999;}.wrapper-dropdown .dropdown li {}.wrapper-dropdown .dropdown li a {display: block;text-decoration: none;color: #333;padding: 10px 0;transition: all 0.3s ease-out;border-bottom: 1px solid #e6e8ea;}.wrapper-dropdown .dropdown li a:hover {color: #38d47f !important;}.wrapper-dropdown .dropdown li:last-of-type a {border: none;}.wrapper-dropdown .dropdown li i {margin-right: 5px;color: inherit;vertical-align: middle;}.wrapper-dropdown .dropdown li:hover a {color: #57a9d9;background-color: #eee;}.wrapper-dropdown.active {border-radius: 5px 5px 0 0;background: #38d47f;box-shadow: none;border-bottom: 2px solid #eceeef;color: white;border: none;}.wrapper-dropdown.active:after {border-color: #FFF transparent;}.wrapper-dropdown.active .dropdown {border-bottom: 1px solid rgba(0,0,0,0.2);max-height: 400px;overflow: scroll;}.explanation {font-size: smaller;color: #999;}.fileupload-wrapper {display: block;position: relative;cursor: pointer;overflow: hidden;width: 100%;max-width: 100%;padding: 5px 10px;margin-top:15px;font-size: 14px;line-height: 22px;color: #777;background-color: #FFF;background-image: none;text-align: center;border: 2px solid #E5E5E5;-webkit-transition: border-color .15s linear;transition: border-color .15s linear;}.fileupload-wrapper .fileupload-message {position: relative;-webkit-transform: translateY(35%);transform: translateY(35%);text-align: center;font-family: sans-serif;font-size: 16px;color: #bbb;}.fileupload-wrapper input {position: absolute;top: 0;right: 0;bottom: 0;left: 0;height: 100%;width: 100%;opacity: 0;cursor: pointer;z-index: 5;}.fileupload-wrapper .fileupload-filename {font-size: 16px;position: relative;transform: translateY(35%);}.fileupload-wrapper p {padding-bottom: 0px;}#create-token-p{margin-top: 1rem;margin-bottom: 0rem;}#save.btn-fullwidth {width: 100%;background-color: #38d47f;}a.btn-success.btn {background-color: #38d47f;width: 100%;border-color: #38d47f;}button.btn.btn-success:disabled {opacity: .85;}@media (min-width: 500px) {#save.btn-fullwidth {font-size: 2rem;}}#save.btn-disabled {font-size: 1rem;width: 14rem;background-color: #ec6161;border: none;}a.btn-success.btn:hover, a.btn-success.btn:focus {color: #fff;}.result {display: none;}#result_cloned_website {height: 10rem;}.jumbotron .btn.btn-clipboard {background-color: #9d8;padding: 5px .5rem;font-size: inherit;cursor: pointer;margin-left: -5px;border-top-left-radius: 0;border-bottom-left-radius: 0;}.btn-clipboard > img {max-width: 15px;height: 15px;}.result-data {width: 80%;border: none;font-family: monospace;padding: 5px 12px;font-size: 17px;line-height: 20px;color: #24292e;vertical-align: middle;background-color: #ffffff;background-repeat: no-repeat;background-position: right 8px center;border: 1px solid #e1e4e8;border-radius: 6px;outline: none;box-shadow: rgba(225, 228, 232, 0.2) 0px 1px 0px 0px inset;border-top-right-radius: 0;border-bottom-right-radius: 0;}.result-data:focus{outline: none;box-shadow: #01ac4b 0px 0px 0px 1px;}.credit-card {display: flex;justify-content: space-evenly;margin-bottom: 15px;}.copy-card .card-detail {display: flex;align-items: flex-end;}.copy-card .card-detail div {display: flex;flex-direction: column;margin-left: 10px;align-items: flex-start;}.copy-card .card-detail .title {font-size: 11.5px;margin-bottom: 5px;font-weight: 300;}.copy-card {display: flex;flex-wrap: wrap;flex-direction: column;justify-content: space-around;font-size: 15px;margin: 15px 0 20px 0;}.copy-card #copy-name, .copy-card #copy-card, .copy-card #copy-expiry, .copy-card #copy-cvc {font-family: monospace;color: #646464;font-weight: 600;font-size: 16px;}.copy-card img {width: 17px;height: 17px;margin-bottom: 3px;}.copy-card img:hover {cursor: pointer;border-radius: 50%;border: 1px solid #24b47e;padding: 2px;}.copy-card .btn-clipboard.hide-button {background: white;border: none;outline: none;height: 20px;}#ccRender {margin-top: 15px;margin-bottom: 20px;width: 335px;}#cccontainer {font-family: 'OCR A Extended', sans-serif;position: relative;margin: auto;background-image: url(/resources/cc-background-AMEX.png);height: 205px;background-repeat: no-repeat;background-size: contain;color: white;}#cccontainer #ccname {left: 30px;top: 75px;position: absolute;font-size: 16pt;}#cccontainer #ccnumber {left: 30px;top: 100px;position: absolute;font-size: 14pt;word-spacing: 0.45em;}#cccontainer #ccexpires {left: 25px;top: 160px;position: absolute;font-size: 13pt;}#cccontainer #cccvc {left: 165px;top: 160px;position: absolute;font-size: 13pt;}@media screen and (max-width: 768px) {.credit-card {flex-wrap: wrap;margin-bottom: 0;}.copy-card {display: grid;grid-template-columns: auto auto;column-gap: 20px;row-gap: 20px;}}@media screen and (max-width: 600px) {.copy-card {column-gap: 10px;row-gap: 10px;margin: 10px auto;}.copy-card span {margin-bottom: 3px;display: flex;}.copy-card .card-detail .title {font-size: 11px;text-align: left;}.copy-card #copy-name, .copy-card #copy-card, .copy-card #copy-expiry, .copy-card #copy-cvc {font-size: 11px;text-align: left;}#cccontainer {height: 190px !important;width: auto !important;background-repeat: no-repeat;background-size: contain;}#cccontainer #ccname {left: 25px !important;top: 70px !important;font-size: 15pt !important;}#cccontainer #ccnumber {left: 25px !important;top: 95px !important;font-size: 13pt !important;}#cccontainer #ccexpires {left: 25px !important;top: 145px !important;font-size: 12pt !important;}#cccontainer #cccvc {left: 145px !important;top: 145px !important;font-size: 12pt !important;}}@media screen and (min-width: 375px) and (max-width: 425px) {#cccontainer {height: 160px !important;width: 240px !important;}#cccontainer #ccname {left: 20px !important;top: 60px !important;font-size: 13pt !important;}#cccontainer #ccnumber {left: 20px !important;top: 80px !important;font-size: 10pt !important;}#cccontainer #ccexpires {left: 20px !important;top: 119px !important;font-size: 9pt !important;}#cccontainer #cccvc {left: 120px !important;top: 119px !important;font-size: 9pt !important;}}@media screen and (min-width: 320px) and (max-width: 375px) {.copy-card .card-detail .title {font-size: 9.5px;text-align: left;}.copy-card #copy-name, .copy-card #copy-card, .copy-card #copy-expiry, .copy-card #copy-cvc {font-size: 10px;text-align: left;}.copy-card img {width: 15px;height: 15px;}#cccontainer {height: 130px !important;width: 190px !important;}#cccontainer #ccname {left: 15px !important;top: 45px !important;font-size: 10pt !important;}#cccontainer #ccnumber {left: 15px !important;top: 60px !important;font-size: 9pt !important;}#cccontainer #ccexpires {left: 15px !important;top: 92px !important;font-size: 9pt !important;}#cccontainer #cccvc {left: 95px !important;top: 92px !important;font-size: 9pt !important;}}.advice {text-align: left;margin-top: 2rem;}.create-link {text-align: center;margin-bottom: 15px;}.create-link a {}a.refresh {font-size: 1.5rem;vertical-align: middle;text-decoration: none;margin-right: 5px;}a.refresh:hover{cursor: pointer;}.pre-like {display: block;font-family: monospace;white-space: pre;margin: 1em 0px;word-break: break-all;overflow: scroll;text-align: left;}.error-field {display: none;color: red;border-bottom: 1px solid red;border-radius: 6px;margin-bottom: 24px;}#endpoints_errors {border: 1px solid red;margin: 24px 0px;padding: 0px 5px;}#endpoints_errors pre {color: #eceeef;background: firebrick;}.map{position: relative;margin-bottom: 10px;margin-top: 10px;height:400px;border-radius: 5px;border: 7px solid #dadada;}.setting .key {text-align: right;}.setting .value {text-align: left;}.bootstrap-switch .bootstrap-switch-handle-off.bootstrap-switch-primary:hover, .bootstrap-switch .bootstrap-switch-handle-on.bootstrap-switch-primary:hover {background-color: #449d44;border-color: #419641;}.bootstrap-switch .bootstrap-switch-handle-off.bootstrap-switch-primary, .bootstrap-switch .bootstrap-switch-handle-on.bootstrap-switch-primary {color: #fff;background-color: #5cb85c;border-color: #5cb85c;}.jumbotron .btn.btn-clipboard {background-color: #5cb85c;border-color: #5cb85c;color: #fff;}.setting .key small {font-size: .6rem;color: #999;vertical-align: top;}@media (min-width: 48em) {.container {max-width: 100%;}}.jumbotron {max-width: 46rem;margin: auto;margin-bottom: 2rem;}a.icon:before {display: block;position: absolute;margin-right: 10px;margin-left: 10px;content: "";height: 40px;width: 40px;background-size: contain;-webkit-transition: all 0.4s ease-in-out;-moz-transition: all 0.4s ease-in-out;transition: all 0.4s ease-in-out;-webkit-transform: translate3d(0, 0, 0);}@media (max-width: 768px) {a.icon:before {width: 20px;height: 20px;}}a.icon-web:before {background-image: url("/resources/web.png");}a.icon-dns:before {background-image: url("/resources/dns.png");}a.icon-web-image:before {background-image: url("/resources/web_image.png");}a.icon-email:before {background-image: url("/resources/email.png");}a.icon-word:before {background-image: url("/resources/word.png");}a.icon-cc:before {background-image: url("/resources/credit-card.png");}a.icon-excel:before {background-image: url("/resources/excel.png");}a.icon-pdf:before {background-image: url("/resources/pdf.png");}a.icon-wireguard:before {background-image: url("/resources/wireguard.png");}a.icon-folder:before {background-image: url("/resources/folder.png");}a.icon-exe:before {background-image: url("/resources/exe.png");}a.icon-clonedsite:before {background-image: url("/resources/clonedsite.png");}a.icon-sqlserver:before {background-image: url("/resources/sqlserver.png");}a.icon-mysql:before {background-image: url("/resources/mysql.png");}a.icon-qrcode:before {background-image: url("/resources/qrcode.png");}a.icon-svn:before {background-image: url("/resources/svn.png");}a.icon-aws:before {background-image: url("/resources/aws.png");}a.icon-redirect:before {background-image: url("/resources/redirect.png");}a.icon-kubernetes:before {background-image: url("/resources/kubernetes_icon.png");}a.icon-log4shell:before {background-image: url("/resources/log4j.png");}a.icon-msreg:before {background-image: url("/resources/msreg.png");}@media (max-width: 768px) {input.form-control:-moz-placeholder, textarea.form-control:-moz-placeholder {font-size: .8rem;}input.form-control::-moz-placeholder, textarea.form-control::-moz-placeholder {font-size: .8rem;}input.form-control:placeholder, textarea.form-control:placeholder {font-size: .8rem;}input.form-control::-webkit-input-placeholder, textarea.form-control::-webkit-input-placeholder {font-size: .8rem;}.setting .key {text-align: center;}.setting .value {text-align: center;margin-bottom: 1rem;}.artifacts{margin-left: 10px;margin-right: 10px;}.row.results{margin-left: 10px;margin-right: 10px;}}@media (max-width: 500px) {input.form-control:-moz-placeholder, textarea.form-control:-moz-placeholder{font-size: .5rem;}input.form-control::-moz-placeholder, textarea.form-control::-moz-placeholder {font-size: .5rem;}input.form-control:placeholder, textarea.form-control:placeholder {font-size: .5rem;}input.form-control::-webkit-input-placeholder, textarea.form-control::-webkit-input-placeholder {font-size: .5rem;}.token-length{font-size: 1.2rem;}.setting .key {text-align: center;}.setting .value {text-align: center;margin-bottom: 1rem;}.artifacts{margin-left: 10px;margin-right: 10px;}.row.results{margin-left: 5px;margin-right: 5px;}a.btn.btn-large.btn-success{font-size: 0.9rem;white-space: pre-wrap;}}#mainsite {font-size: smaller;padding-top: 1.5rem;}.mysql-encoded-button {margin-top: 10px;text-align: center;}.mysql-text {text-align: left;}.mysqldownload {cursor: pointer;}#wg_conf {background: #f6f6f6;display: block;white-space: pre;overflow-x: scroll;max-width: 100%;min-width: 100px;padding: 0;text-align: left;border-radius: 8px;padding-bottom: 12px;padding-left: 12px;padding-top: 12px;margin-bottom: 7px;margin-top: 7px;margin-left: 0px;}#wireguard-app {margin-bottom: 16px;}#wireguard-app img {height: 40px;}a:focus.what-is-this, a:hover.what-is-this {text-decoration: none;}a.what-is-this svg {width: 19px;} \ No newline at end of file + .logo {height: 32px;}.header a {color: #38d47f;}.hidden {display: none!important;}.step {border-bottom: 2px solid #fff;padding: 1rem 0rem;}.goodtick {height: 100px;margin: auto;margin-top: 20px;margin-bottom: 20px;}.error-outline {outline: none;border-color: #1ecaed;box-shadow: 0 0 13px #ec6161;}.success-outline {outline: none;border-color: #1ecaed;box-shadow: 0 0 10px #38d47f;}a.fileupload-clear:hover {text-decoration: none;}.jumbotron {padding: 0;position: relative;}.create {z-index: 1;position: relative;padding: 2rem 1rem;}.create-hidden {transition:opacity 0.5s ease;display: none;}.history-header {text-align:center;padding: 2rem 1rem;margin-bottom: 2rem;background-color: #eceeef;border-radius: .3rem;}.details-header{padding:0.7rem;text-align: center;}.footer {text-align: center;}.footer .banner {display: block;max-width: 46rem;margin: auto;margin-bottom: 20px;height: 210px;background-image: url('/resources/thinkst-canary-banner-hi-res-2.png');background-repeat: no-repeat;background-position: center;background-size: contain;}.footer a {color: #38d47f;}@media screen and (max-width: 425px) {.footer {padding: 0;font-size: 14px;}.footer p {margin-bottom: 10px;}.footer .banner {background-image: url('/resources/thinkst-canary-banner-mobile-2.png');height: 300px;margin-top: 10px;}}@media screen and (max-width: 375px) {.footer .banner {height: 335px;}}.incident-size{margin :0px auto;display: none;}.incident-list{overflow: auto;position: relative;margin-bottom: 10px;margin-top: 10px;}.incident-item{width: 100%;transition:opacity 0.5s ease;background-color: aliceblue;color: darkslategrey;border-radius: .3rem;margin-bottom: 0.5rem;cursor: pointer;}.incident-item-details{display:none;padding:1rem;}.incident-item:hover{outline: none;border-color: #1ecaed;box-shadow: 0 0 10px green;}.header_row{font-weight:bold;}.incident-item:first-child{margin-top:0.5rem;}.success {width: 100%;z-index: 0;transition:opacity 0.5s ease;background-color: #eceeef;color: darkslategrey;padding-top: 1rem;padding-bottom: 2rem;border-radius: .3rem;display: none;}.success-visible {z-index: 2;display: block;}.results {padding-top: 1rem;}input.form-control, input.fileupload, textarea {margin-top: 10px;margin-bottom: 10px;text-align: center;}input.form-control::-webkit-input-placeholder, textarea.form-control::-webkit-input-placeholder {color: #bbb;}input.form-control:-moz-placeholder, textarea.form-control:-moz-placeholder{color: #bbb;}input.form-control::-moz-placeholder, textarea.form-control::-moz-placeholder {color: #bbb;}input.form-control:placeholder, textarea.form-control:placeholder {color: #bbb;}.form-control {border: 2px solid #dadada;border-radius: 7px;}.form-control:focus {outline: none;border-color: #dadada;box-shadow: 0 0 14px #4a4a4a;}.step:last-child {border-bottom: none;padding-bottom: 0rem;}.wrapper-dropdown {position: relative;margin: 0 auto;margin-bottom: 10px;padding: 12px 15px;background: #fff;border-radius: 5px;cursor: pointer;outline: none;transition: all 0.3s ease-out;}.wrapper-dropdown:after {content: "";width: 0;height: 0;position: absolute;top: 50%;right: 15px;margin-top: -3px;border-width: 6px 6px 0 6px;border-style: solid;border-color: #38d47f transparent;}.success-outline.wrapper-dropdown:after {border-color: green transparent;}.wrapper-dropdown .dropdown {position: absolute;top: 100%;left: 0;right: 0;background: #fff;border-radius: 0 0 5px 5px;border: 1px solid rgba(0,0,0,0.2);border-top: none;border-bottom: none;list-style: none;transition: all 0.3s ease-out;padding-left: 0px;max-height: 0;overflow: hidden;z-index: 999;}.wrapper-dropdown .dropdown li {}.wrapper-dropdown .dropdown li a {display: block;text-decoration: none;color: #333;padding: 10px 0;transition: all 0.3s ease-out;border-bottom: 1px solid #e6e8ea;}.wrapper-dropdown .dropdown li a:hover {color: #38d47f !important;}.wrapper-dropdown .dropdown li:last-of-type a {border: none;}.wrapper-dropdown .dropdown li i {margin-right: 5px;color: inherit;vertical-align: middle;}.wrapper-dropdown .dropdown li:hover a {color: #57a9d9;background-color: #eee;}.wrapper-dropdown.active {border-radius: 5px 5px 0 0;background: #38d47f;box-shadow: none;border-bottom: 2px solid #eceeef;color: white;border: none;}.wrapper-dropdown.active:after {border-color: #FFF transparent;}.wrapper-dropdown.active .dropdown {border-bottom: 1px solid rgba(0,0,0,0.2);max-height: 400px;overflow: scroll;}input#search-token {width: 100%;height: 45px;border: none;border-bottom: 1px solid #eceeef;text-align: center;color: #888888;}input#search-token:focus, input#search-token:focus-visible {outline: #eceeef;}input#search-token::placeholder {color: #c7c7c7;}.explanation {font-size: smaller;color: #999;}.fileupload-wrapper {display: block;position: relative;cursor: pointer;overflow: hidden;width: 100%;max-width: 100%;padding: 5px 10px;margin-top:15px;font-size: 14px;line-height: 22px;color: #777;background-color: #FFF;background-image: none;text-align: center;border: 2px solid #E5E5E5;-webkit-transition: border-color .15s linear;transition: border-color .15s linear;}.fileupload-wrapper .fileupload-message {position: relative;-webkit-transform: translateY(35%);transform: translateY(35%);text-align: center;font-family: sans-serif;font-size: 16px;color: #bbb;}.fileupload-wrapper input {position: absolute;top: 0;right: 0;bottom: 0;left: 0;height: 100%;width: 100%;opacity: 0;cursor: pointer;z-index: 5;}.fileupload-wrapper .fileupload-filename {font-size: 16px;position: relative;transform: translateY(35%);}.fileupload-wrapper p {padding-bottom: 0px;}#create-token-p{margin-top: 1rem;margin-bottom: 0rem;}#save.btn-fullwidth {width: 100%;background-color: #38d47f;}a.btn-success.btn {background-color: #38d47f;width: 100%;border-color: #38d47f;color: white;}button.btn.btn-success:disabled {opacity: .85;}@media (min-width: 500px) {#save.btn-fullwidth {font-size: 2rem;}}#save.btn-disabled {font-size: 1rem;width: 14rem;background-color: #ec6161;border: none;}a.btn-success.btn:hover, a.btn-success.btn:focus {color: #fff;}.result {display: none;}#result_cloned_website {height: 10rem;}.jumbotron .btn.btn-clipboard {background-color: #9d8;padding: 5px .5rem;font-size: inherit;cursor: pointer;margin-left: -5px;border-top-left-radius: 0;border-bottom-left-radius: 0;}.btn-clipboard > img {max-width: 15px;height: 15px;}.result-data {width: 80%;border: none;font-family: monospace;padding: 5px 12px;font-size: 17px;line-height: 20px;color: #24292e;vertical-align: middle;background-color: #ffffff;background-repeat: no-repeat;background-position: right 8px center;border: 1px solid #e1e4e8;border-radius: 6px;outline: none;box-shadow: rgba(225, 228, 232, 0.2) 0px 1px 0px 0px inset;border-top-right-radius: 0;border-bottom-right-radius: 0;}.result-data:focus{outline: none;box-shadow: #01ac4b 0px 0px 0px 1px;}.credit-card {display: flex;justify-content: space-evenly;margin-bottom: 15px;}.copy-card .card-detail {display: flex;align-items: flex-end;}.copy-card .card-detail div {display: flex;flex-direction: column;margin-left: 10px;align-items: flex-start;}.copy-card .card-detail .title {font-size: 11.5px;margin-bottom: 5px;font-weight: 300;}.copy-card {display: flex;flex-wrap: wrap;flex-direction: column;justify-content: space-around;font-size: 15px;margin: 15px 0 20px 0;}.copy-card #copy-name, .copy-card #copy-card, .copy-card #copy-expiry, .copy-card #copy-cvc {font-family: monospace;color: #646464;font-weight: 600;font-size: 16px;}.copy-card img {width: 17px;height: 17px;margin-bottom: 3px;}.copy-card img:hover {cursor: pointer;border-radius: 50%;border: 1px solid #24b47e;padding: 2px;}.copy-card .btn-clipboard.hide-button {background: white;border: none;outline: none;height: 22px;width: 22px;background-image: url(/resources/copy-inactive.svg);background-size: 22px;background-repeat: no-repeat;background-position: center;}.copy-card .btn-clipboard.hide-button:hover {background-image: url(/resources/copy-hover.svg);cursor: pointer;}#ccRender {margin-top: 15px;margin-bottom: 20px;width: 335px;}#cccontainer {font-family: 'OCR A Extended', sans-serif;position: relative;margin: auto;background-image: url(/resources/cc-background-AMEX.png);height: 205px;background-repeat: no-repeat;background-size: contain;color: white;}#cccontainer #ccname {left: 30px;top: 75px;position: absolute;font-size: 16pt;}#cccontainer #ccnumber {left: 30px;top: 100px;position: absolute;font-size: 14pt;word-spacing: 0.45em;}#cccontainer #ccexpires {left: 25px;top: 160px;position: absolute;font-size: 13pt;}#cccontainer #cccvc {left: 165px;top: 160px;position: absolute;font-size: 13pt;}@media screen and (max-width: 768px) {.credit-card {flex-wrap: wrap;margin-bottom: 0;}.copy-card {display: grid;grid-template-columns: auto auto;column-gap: 20px;row-gap: 20px;}}@media screen and (max-width: 600px) {.copy-card {column-gap: 10px;row-gap: 10px;margin: 10px auto;}.copy-card span {margin-bottom: 3px;display: flex;}.copy-card .card-detail .title {font-size: 11px;text-align: left;}.copy-card #copy-name, .copy-card #copy-card, .copy-card #copy-expiry, .copy-card #copy-cvc {font-size: 11px;text-align: left;}#cccontainer {height: 190px !important;width: auto !important;background-repeat: no-repeat;background-size: contain;}#cccontainer #ccname {left: 25px !important;top: 70px !important;font-size: 15pt !important;}#cccontainer #ccnumber {left: 25px !important;top: 95px !important;font-size: 13pt !important;}#cccontainer #ccexpires {left: 25px !important;top: 145px !important;font-size: 12pt !important;}#cccontainer #cccvc {left: 145px !important;top: 145px !important;font-size: 12pt !important;}}@media screen and (min-width: 375px) and (max-width: 425px) {#cccontainer {height: 160px !important;width: 240px !important;}#cccontainer #ccname {left: 20px !important;top: 60px !important;font-size: 13pt !important;}#cccontainer #ccnumber {left: 20px !important;top: 80px !important;font-size: 10pt !important;}#cccontainer #ccexpires {left: 20px !important;top: 119px !important;font-size: 9pt !important;}#cccontainer #cccvc {left: 120px !important;top: 119px !important;font-size: 9pt !important;}}@media screen and (min-width: 320px) and (max-width: 375px) {.copy-card .card-detail .title {font-size: 9.5px;text-align: left;}.copy-card #copy-name, .copy-card #copy-card, .copy-card #copy-expiry, .copy-card #copy-cvc {font-size: 10px;text-align: left;}.copy-card img {width: 15px;height: 15px;}#cccontainer {height: 130px !important;width: 190px !important;}#cccontainer #ccname {left: 15px !important;top: 45px !important;font-size: 10pt !important;}#cccontainer #ccnumber {left: 15px !important;top: 60px !important;font-size: 9pt !important;}#cccontainer #ccexpires {left: 15px !important;top: 92px !important;font-size: 9pt !important;}#cccontainer #cccvc {left: 95px !important;top: 92px !important;font-size: 9pt !important;}}.advice {text-align: left;margin-top: 2rem;}.advice li {list-style: none;position: relative;margin: 20px 0 20px 0;font-size: 15px;}.advice li::before {content: '';background-image: url('/resources/idea.svg');background-size: contain;background-repeat: no-repeat;background-position: center;background-size: 2em;height: 100%;width: 30px;position: absolute;left: -40px;}@media screen and (max-width: 600px) {.advice li {margin: 10px 0 10px 0;font-size: 14px;}}.create-link {text-align: center;margin-bottom: 15px;}.create-link a {}a.refresh {font-size: 1.5rem;vertical-align: middle;text-decoration: none;margin-right: 5px;}a.refresh:hover{cursor: pointer;}.pre-like {display: block;font-family: monospace;white-space: pre;margin: 1em 0px;word-break: break-all;overflow: scroll;text-align: left;}.error-field {display: none;color: red;border-bottom: 1px solid red;border-radius: 6px;margin-bottom: 24px;}#endpoints_errors {border: 1px solid red;margin: 24px 0px;padding: 0px 5px;}#endpoints_errors pre {color: #eceeef;background: firebrick;}.map{position: relative;margin-bottom: 10px;margin-top: 10px;height:400px;border-radius: 5px;border: 7px solid #dadada;}.setting .key {text-align: right;}.setting .value {text-align: left;}.bootstrap-switch .bootstrap-switch-handle-off.bootstrap-switch-primary:hover, .bootstrap-switch .bootstrap-switch-handle-on.bootstrap-switch-primary:hover {background-color: #449d44;border-color: #419641;}.bootstrap-switch .bootstrap-switch-handle-off.bootstrap-switch-primary, .bootstrap-switch .bootstrap-switch-handle-on.bootstrap-switch-primary {color: #fff;background-color: #38d47f;border-color: #38d47f;}.jumbotron .btn.btn-clipboard {background-color: #38d47f;border-color: #38d47f;color: #fff;}.setting .key small {font-size: .6rem;color: #999;vertical-align: top;}@media (min-width: 48em) {.container {max-width: 100%;}}.jumbotron {max-width: 46rem;margin: auto;margin-bottom: 2rem;}a.icon:before {display: block;position: absolute;margin-right: 10px;margin-left: 10px;content: "";height: 40px;width: 40px;background-size: contain;-webkit-transition: all 0.4s ease-in-out;-moz-transition: all 0.4s ease-in-out;transition: all 0.4s ease-in-out;-webkit-transform: translate3d(0, 0, 0);}@media (max-width: 768px) {a.icon:before {width: 20px;height: 20px;}}a.icon-web:before {background-image: url("/resources/web.png");}a.icon-dns:before {background-image: url("/resources/dns.png");}a.icon-web-image:before {background-image: url("/resources/web_image.png");}a.icon-email:before {background-image: url("/resources/email.png");}a.icon-word:before {background-image: url("/resources/word.png");}a.icon-cc:before {background-image: url("/resources/credit-card.png");}a.icon-excel:before {background-image: url("/resources/excel.png");}a.icon-pdf:before {background-image: url("/resources/pdf.png");}a.icon-wireguard:before {background-image: url("/resources/wireguard.png");}a.icon-folder:before {background-image: url("/resources/folder.png");}a.icon-exe:before {background-image: url("/resources/exe.png");}a.icon-clonedsite:before {background-image: url("/resources/clonedsite.png");}a.icon-sqlserver:before {background-image: url("/resources/sqlserver.png");}a.icon-mysql:before {background-image: url("/resources/mysql.png");}a.icon-qrcode:before {background-image: url("/resources/qrcode.png");}a.icon-svn:before {background-image: url("/resources/svn.png");}a.icon-aws:before {background-image: url("/resources/aws.png");}a.icon-azure-id:before {background-image: url("/resources/azure-id.png");}a.icon-redirect:before {background-image: url("/resources/redirect.png");}a.icon-kubernetes:before {background-image: url("/resources/kubernetes_icon.png");}a.icon-log4shell:before {background-image: url("/resources/log4j.png");}a.icon-msreg:before {background-image: url("/resources/msreg.png");}@media (max-width: 768px) {input.form-control:-moz-placeholder, textarea.form-control:-moz-placeholder {font-size: .8rem;}input.form-control::-moz-placeholder, textarea.form-control::-moz-placeholder {font-size: .8rem;}input.form-control:placeholder, textarea.form-control:placeholder {font-size: .8rem;}input.form-control::-webkit-input-placeholder, textarea.form-control::-webkit-input-placeholder {font-size: .8rem;}.setting .key {text-align: center;}.setting .value {text-align: center;margin-bottom: 1rem;}.artifacts{margin-left: 10px;margin-right: 10px;}.row.results{margin-left: 10px;margin-right: 10px;}}@media (max-width: 500px) {input.form-control:-moz-placeholder, textarea.form-control:-moz-placeholder{font-size: .5rem;}input.form-control::-moz-placeholder, textarea.form-control::-moz-placeholder {font-size: .5rem;}input.form-control:placeholder, textarea.form-control:placeholder {font-size: .5rem;}input.form-control::-webkit-input-placeholder, textarea.form-control::-webkit-input-placeholder {font-size: .5rem;}.token-length{font-size: 1.2rem;}.setting .key {text-align: center;}.setting .value {text-align: center;margin-bottom: 1rem;}.artifacts{margin-left: 10px;margin-right: 10px;}.row.results{margin-left: 5px;margin-right: 5px;}a.btn.btn-large.btn-success{font-size: 0.9rem;white-space: pre-wrap;}}#mainsite {font-size: smaller;padding-top: 1.5rem;}.mysql-encoded-button {margin-top: 10px;text-align: center;}.mysql-text {text-align: left;}.mysqldownload {cursor: pointer;}#wg_conf {background: #f6f6f6;display: block;white-space: pre;overflow-x: scroll;max-width: 100%;min-width: 100px;padding: 0;text-align: left;border-radius: 8px;padding-bottom: 12px;padding-left: 12px;padding-top: 12px;margin-bottom: 7px;margin-top: 7px;margin-left: 0px;}#wireguard-app {margin-bottom: 16px;}#wireguard-app img {height: 40px;}a:focus.what-is-this, a:hover.what-is-this {text-decoration: none;}a.what-is-this svg {width: 19px;} diff --git a/tests/integration/test_against_token_server.py b/tests/integration/test_against_token_server.py index 9f88c8cd8..ae5a33c83 100644 --- a/tests/integration/test_against_token_server.py +++ b/tests/integration/test_against_token_server.py @@ -658,41 +658,3 @@ def test_token_error_codes( resp.raise_for_status() code = resp.json()[error] assert code == error_code - - -# try: -# token_request_details = parse_obj_as(AnyTokenRequest, token_request_data) -# except ValidationError: # DESIGN: can we specialise on what went wrong? -# return response_error(1, "No email/webhook supplied or malformed request") - -# if not token_request_details.memo: -# return response_error(2, "No memo supplied") - -# if token_request_details.webhook_url: -# try: -# validate_webhook( -# token_request_details.webhook_url, token_request_details.token_type -# ) -# except requests.exceptions.HTTPError: -# # raise HTTPException(status_code=400, detail="Failed to validate webhook") -# return response_error( -# 3, "Invalid webhook supplied. Confirm you can POST to this URL." -# ) -# except requests.exceptions.ConnectTimeout: -# # raise HTTPException( -# # status_code=400, detail="Failed to validate webhook - timed out." -# # ) -# return response_error( -# 3, "Webhook timed out. Confirm you can POST to this URL." -# ) - -# if token_request_details.email: -# if not is_valid_email(token_request_details.email): -# return response_error(5, "Invalid email supplied") - -# if is_email_blocked(token_request_details.email): -# # raise HTTPException(status_code=400, detail="Email is blocked.") -# return response_error( -# 6, -# "Blocked email supplied. Please see our Acceptable Use Policy at https://canarytokens.org/legal", -# ) diff --git a/tests/units/test_extendtoken.py b/tests/units/test_extendtoken.py new file mode 100644 index 000000000..c53905a33 --- /dev/null +++ b/tests/units/test_extendtoken.py @@ -0,0 +1,24 @@ +import os +from distutils.util import strtobool + +import pytest + +from canarytokens import extendtoken +from canarytokens.settings import FrontendSettings + +settings = FrontendSettings("../frontend/frontend.env") + + +@pytest.mark.skipif( + not strtobool(os.getenv("MAKE_CARD", "False")), + reason="We don't want to use up a cc every time we run tests", +) +def test_create_cc(): + eapi = extendtoken.ExtendAPI( + email=settings.EXTEND_EMAIL, + password=settings.EXTEND_PASSWORD.get_secret_value(), + card_name=settings.EXTEND_CARD_NAME, + ) + token_url = "http://canarytokens.com/static/tags/traffic/u27uxknlv3x2wzpe5ufkehpmh/contact.php" + cc = eapi.create_credit_card(token_url=token_url) + assert len(cc.number) == 15 diff --git a/tests/units/test_frontend.py b/tests/units/test_frontend.py index c5a6dd38d..cf0828f90 100644 --- a/tests/units/test_frontend.py +++ b/tests/units/test_frontend.py @@ -15,6 +15,8 @@ AWSKeyTokenRequest, AWSKeyTokenResponse, BrowserScannerSettingsRequest, + CCTokenRequest, + CCTokenResponse, CustomBinaryTokenRequest, CustomBinaryTokenResponse, CustomImageTokenRequest, @@ -129,10 +131,12 @@ def test_generate_log4shell_token(test_client: TestClient) -> None: # TODO: test client uploads is not added. # Skipping these types for now. set_of_unsupported_request_classes = [ + CCTokenRequest, CustomImageTokenRequest, CustomBinaryTokenRequest, ] set_of_unsupported_response_classes = [ + CCTokenResponse, CustomImageTokenResponse, CustomBinaryTokenResponse, ] @@ -247,6 +251,7 @@ def test_download_canarydrop_csv_details( (PDFTokenRequest, PDFTokenResponse, DownloadPDFRequest), (MySQLTokenRequest, MySQLTokenResponse, DownloadMySQLRequest), # (AWSKeyTokenRequest, AWSKeyTokenResponse, DownloadAWSKeysRequest), + # (CCTokenRequest, CCTokenResponse, DownloadCCRequest), (KubeconfigTokenRequest, KubeconfigTokenResponse, DownloadKubeconfigRequest), (QRCodeTokenRequest, QRCodeTokenResponse, DownloadQRCodeRequest), ], diff --git a/tests/units/test_http_channel.py b/tests/units/test_http_channel.py index df686f234..2593666ef 100644 --- a/tests/units/test_http_channel.py +++ b/tests/units/test_http_channel.py @@ -4,13 +4,19 @@ from pydantic import EmailStr from twisted.internet.address import IPv4Address from twisted.web.http import Request +from twisted.web.http_headers import Headers from twisted.web.test.requesthelper import DummyChannel from twisted.web.test.test_web import DummyRequest from canarytokens import canarydrop, queries from canarytokens.awskeys import get_aws_key from canarytokens.channel_http import ChannelHTTP -from canarytokens.models import AWSKeyTokenHistory, TokenTypes +from canarytokens.models import ( + AWSKeyTokenHistory, + CCTokenHistory, + CreditCard, + TokenTypes, +) from canarytokens.settings import FrontendSettings, Settings from canarytokens.switchboard import Switchboard from canarytokens.tokens import Canarytoken @@ -53,7 +59,8 @@ def test_channel_http_GET(setup_db, settings, frontend_settings, token_type): client = IPv4Address(type="TCP", host="127.0.0.1", port=8686) request = DummyRequest("/") request.client = client - request.path = cd.generate_random_url(["http://127.0.0.1:8686"]) + request.uri = cd.generate_random_url(["http://127.0.0.1:8686"]).encode() + request.path = request.uri[request.uri.index(b"/", 8) :] # noqa: E203 http_channel.canarytoken_page.render_GET(request) cd_updated = queries.get_canarydrop(canarytoken=cd.canarytoken) assert len(cd_updated.triggered_details.hits) == 1 @@ -104,7 +111,8 @@ def test_channel_http_GET_and_POST_back( queries.save_canarydrop(cd) request = Request(channel=DummyChannel()) - request.path = cd.generate_random_url(["http://127.0.0.1"]) + request.uri = cd.generate_random_url(["http://127.0.0.1:8686"]).encode() + request.path = request.uri[request.uri.index(b"/", 8) :] # noqa: E203 request.args = request_args request.method = b"GET" http_channel.site.resource.render(request) @@ -177,7 +185,7 @@ def test_channel_http_GET_random_endpoint(setup_db, settings, frontend_settings) # TODO: Add random endpoints. request.path = "//".join( cd.generate_random_url(["http://127.0.0.1"]).split("/")[::2][:2] - ) + ).encode() request.args = request_args request.method = b"GET" resp = http_channel.site.resource.render(request) @@ -190,7 +198,7 @@ def test_channel_http_GET_random_endpoint(setup_db, settings, frontend_settings) request = Request(channel=DummyChannel()) # TODO: Add random endpoints. - request.path = "http://127.0.0.1/this/has/no-token" + request.path = b"http://127.0.0.1/this/has/no-token" request.args = request_args request.method = b"POST" @@ -275,3 +283,90 @@ def test_POST_aws_token_back( "eventName" ] == ["GetCallerIdentity"] assert cd.type == cd_updated.type + + +def test_GET_cc_token_back( + frontend_settings: FrontendSettings, + settings: Settings, + setup_db: None, +): + http_channel = ChannelHTTP( + settings=settings, + frontend_settings=frontend_settings, + switchboard=switchboard, + ) + + canarytoken = Canarytoken() + cc = CreditCard( + id="vc_1234", + number="370012340001234", + name="Bob Smith", + billing_zip=10210, + address="some billing address", + ) + + cd = canarydrop.Canarydrop( + type=TokenTypes.CC, + triggered_details=CCTokenHistory(), + alert_email_enabled=False, + alert_email_recipient=EmailStr("email@test.com"), + alert_webhook_enabled=False, + alert_webhook_url=None, + canarytoken=canarytoken, + memo="memo", + cc_id=cc.id, + cc_kind=cc.kind, + cc_number=cc.number, + cc_cvc=cc.cvc, + cc_expiration=cc.expiration, + cc_name=cc.name, + cc_billing_zip=cc.billing_zip, + cc_address=cc.address, + cc_rendered_html=cc.render_html(), + ) + queries.save_canarydrop(cd) + + # stripped down version of the data we get from Extend + resp = { + "transaction": { + "virtualCardId": cc.id, + "vcnLast4": cc.number[-4:], + "authMerchantAmountCents": "639", + "merchantName": "ACME Airline Co.", + } + } + # mimic the processing done on the above + txn = resp.get("transaction") + last4 = txn.get("vcnLast4", "") + amount = "{:.2f}".format(int(txn.get("authMerchantAmountCents", "0")) / 100) + merchant = txn.get("merchantName", "") + + vc_info = { + "virtualCard": { + "id": cc.id, + "last4": cc.number[-4:], + "notes": cd.get_url( + [f"{settings.DOMAINS[0]}:{settings.CHANNEL_HTTP_PORT}"] + ), + } + } + + request = Request(channel=DummyChannel()) + request.args = {} + request.uri = vc_info["virtualCard"].get("notes").encode() + request.path = request.uri[request.uri.index(b"/", 8) :] # noqa: E203 + headers = { + "Type": ["Credit Card"], + "Last4": [last4], + "Amount": [amount], + "Merchant": [merchant], + } + request.requestHeaders = Headers(headers) + request.method = b"GET" + http_channel.site.resource.render(request) + + cd_updated = queries.get_canarydrop(canarytoken=cd.canarytoken) + + assert cd_updated is not None + assert len(cd_updated.triggered_details.hits) == 1 + assert cd.type == cd_updated.type