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 @@
{%- endmacro %}
+
+{% macro textareacopydownload(name, download_fmt, value="")%}
+
+
+{%- 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