From 8dee4db7a66e8cb10677dcb32f82ef65378d9435 Mon Sep 17 00:00:00 2001 From: "W. Leighton Dawson" Date: Sun, 8 Dec 2024 00:11:22 +0200 Subject: [PATCH] Add Fake IdP SAML App tokens: Backend and tests (#622) * Add SAML IdP App token backend and tests * Update notification template and rename generated file * Add token alert icon for email * Improve webdav tests; add missing env vars to CI for tests --- .github/workflows/test.yml | 4 + canarytokens/canarydrop.py | 29 +++-- canarytokens/channel_http.py | 32 +++++- canarytokens/models.py | 107 ++++++++++++++++++ canarytokens/queries.py | 1 + canarytokens/saml.py | 22 ++++ canarytokens/tokens.py | 25 +++- frontend/app.py | 41 +++++-- .../_generated_dont_edit_notification.html | 58 +++++++++- templates/emails/notification.mjml | 26 +++++ .../canarytoken-icons/idp_app.png | Bin 0 -> 20969 bytes tests/data/sample_saml_data.txt | 1 + tests/integration/test_saml_token.py | 61 ++++++++++ tests/units/test_frontend.py | 2 - tests/units/test_saml.py | 24 ++++ tests/utils.py | 2 + 16 files changed, 408 insertions(+), 27 deletions(-) create mode 100644 canarytokens/saml.py create mode 100644 templates/static/notification-email/canarytoken-icons/idp_app.png create mode 100644 tests/data/sample_saml_data.txt create mode 100644 tests/integration/test_saml_token.py create mode 100644 tests/units/test_saml.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e39d16e61..b43d2ff69 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -110,6 +110,10 @@ jobs: export CANARY_MAILGUN_API_KEY=${{ secrets.TESTING_MAILGUN_API_KEY }} export CANARY_SENTRY_ENVIRONMENT=ci export CANARY_WEB_IMAGE_UPLOAD_PATH=../uploads + export CANARY_WEBDAV_SERVER=test + export CANARY_CLOUDFLARE_ACCOUNT_ID=test + export CANARY_CLOUDFLARE_API_TOKEN=test + export CANARY_CLOUDFLARE_NAMESPACE=test cd tests poetry run coverage run --source=../canarytokens --omit="integration/test_custom_binary.py,integration/test_sql_server_token.py" -m pytest units --runv3 -v diff --git a/canarytokens/canarydrop.py b/canarytokens/canarydrop.py index 24bdace7f..59833c3a0 100644 --- a/canarytokens/canarydrop.py +++ b/canarytokens/canarydrop.py @@ -36,6 +36,7 @@ AnyTokenExposedHit, BrowserScannerSettingsRequest, EmailSettingsRequest, + IdPAppType, PWAType, TokenTypes, User, @@ -170,6 +171,9 @@ class Canarydrop(BaseModel): key_exposed_details: Optional[AnyTokenExposedHit] = None + idp_app_entity_id: Optional[str] + idp_app_type: Optional[IdPAppType] + @root_validator(pre=True) def _validate_triggered_details(cls, values): """ @@ -308,6 +312,7 @@ def generate_random_url( self, canary_domains: list[str], page: Optional[str] = None, + use_path_elements: bool = True, skip_cache: bool = False, ) -> str: """ @@ -320,26 +325,28 @@ def generate_random_url( return self.generated_url (path_elements, pages) = self.get_url_components() - generated_url = random.choice(canary_domains) + "/" path = [] - for count in range(0, random.randint(1, 3)): - if len(path_elements) == 0: - break - - elem = path_elements[random.randint(0, len(path_elements) - 1)] - path.append(elem) - path_elements.remove(elem) + if use_path_elements: + path = random.sample( + path_elements, random.randint(1, min(3, len(path_elements))) + ) path.append(self.canarytoken.value()) + path.append(random.choice(pages) if page is None else page) - path.append(pages[random.randint(0, len(pages) - 1)] if page is None else page) + generated_url = random.choice(canary_domains) + "/" generated_url += "/".join(path) # cache if not skip_cache: self.generated_url = generated_url return generated_url - def get_url(self, canary_domains: list[str], page: Optional[str] = None): - return self.generate_random_url(canary_domains, page) + def get_url( + self, + canary_domains: list[str], + page: Optional[str] = None, + use_path_elements: Optional[bool] = True, + ): + return self.generate_random_url(canary_domains, page, use_path_elements) def generate_random_hostname(self, with_random=False, nxdomain=False): """ diff --git a/canarytokens/channel_http.py b/canarytokens/channel_http.py index 007343f5f..915a10f9f 100644 --- a/canarytokens/channel_http.py +++ b/canarytokens/channel_http.py @@ -16,6 +16,7 @@ from canarytokens.exceptions import NoCanarytokenFound, NoCanarydropFound from canarytokens.models import AnyTokenHit, AWSKeyTokenHit, TokenTypes from canarytokens.queries import get_canarydrop +from canarytokens.saml import SAML_POST_ARG from canarytokens.settings import FrontendSettings, SwitchboardSettings from canarytokens.switchboard import Switchboard from canarytokens.tokens import Canarytoken, GIF, get_template_env @@ -179,7 +180,6 @@ def render_POST(self, request: Request): # noqa: C901 # -getting an aws trigger (key == aws_s3) # otherwise, slack api token data perhaps # store the info and don't re-render - if canarydrop.type == TokenTypes.AWS_KEYS: token_hit = Canarytoken._parse_aws_key_trigger(request) if isinstance(token_hit, AWSKeyTokenHit): @@ -237,8 +237,34 @@ def render_POST(self, request: Request): # noqa: C901 ) # TODO: These returns are not really needed return b"failed" - else: - return self.render_GET(request) + elif canarydrop.type == TokenTypes.IDP_APP: + if SAML_POST_ARG in request.args: + return self.render_GET(request) + key = request.args.get(b"key", [None])[0] + if (key := coerce_to_float(key)) and token: + additional_info = { + k.decode(): v + for k, v in request.args.items() + if k.decode() not in ["key", "canarytoken", "name"] + } + canarydrop.add_additional_info_to_hit( + hit_time=key, + additional_info={ + request.args[b"name"][0].decode(): additional_info + }, + ) + self.dispatch( + canarydrop=canarydrop, + token_hit=canarydrop.triggered_details.hits[-1], + ) + return b"success" + else: + log.info( + f"Either {key=} or {token=} were falsy. Dropping this request." + ) + # TODO: These returns are not really needed + return b"failed" + return self.render_GET(request) class ChannelHTTP: diff --git a/canarytokens/models.py b/canarytokens/models.py index 6a0f5217f..91b0524d3 100644 --- a/canarytokens/models.py +++ b/canarytokens/models.py @@ -304,6 +304,7 @@ class TokenTypes(str, enum.Enum): WINDOWS_FAKE_FS = "windows_fake_fs" CC = "cc" PWA = "pwa" + IDP_APP = "idp_app" SLACK_API = "slack_api" LEGACY = "legacy" @@ -352,6 +353,7 @@ def __str__(self) -> str: TokenTypes.PWA: "Fake app", TokenTypes.SLACK_API: "Slack API", TokenTypes.LEGACY: "Legacy", + TokenTypes.IDP_APP: "SAML2 IdP App", } GeneralHistoryTokenType = Literal[ @@ -826,6 +828,91 @@ class CreditCardV2TokenRequest(TokenRequest): cf_turnstile_response: Optional[str] +class IdPAppType(enum.Enum): + AWS = "aws" + AZURE = "azure" + BITWARDEN = "bitwarden" + DROPBOX = "dropbox" + DUO = "duo" + ELASTICSEARCH = "elasticsearch" + FRESHBOOKS = "freshbooks" + GCLOUD = "gcloud" + GDRIVE = "gdrive" + GITHUB = "github" + GITLAB = "gitlab" + GMAIL = "gmail" + INTUNE = "intune" + JAMF = "jamf" + JIRA = "jira" + KIBANA = "kibana" + LASTPASS = "lastpass" + MS365 = "ms365" + MSTEAMS = "msteams" + ONEDRIVE = "onedrive" + ONEPASSWORD = "onepassword" + OUTLOOK = "outlook" + PAGERDUTY = "pagerduty" + SAGE = "sage" + SALESFORCE = "salesforce" + SAP = "sap" + SLACK = "slack" + VIRTRU = "virtru" + ZENDESK = "zendesk" + ZOHO = "zoho" + ZOOM = "zoom" + + +IDP_APP_TITLES = { + IdPAppType.AWS: "AWS", + IdPAppType.AZURE: "Azure", + IdPAppType.BITWARDEN: "Bitwarden", + IdPAppType.DROPBOX: "Dropbox", + IdPAppType.DUO: "Duo", + IdPAppType.ELASTICSEARCH: "Elasticsearch", + IdPAppType.FRESHBOOKS: "Freshbooks", + IdPAppType.GCLOUD: "Google Cloud", + IdPAppType.GDRIVE: "Google Drive", + IdPAppType.GITHUB: "GitHub", + IdPAppType.GITLAB: "GitLab", + IdPAppType.GMAIL: "Gmail", + IdPAppType.INTUNE: "Intune", + IdPAppType.JAMF: "JAMF", + IdPAppType.JIRA: "Jira", + IdPAppType.KIBANA: "Kibana", + IdPAppType.LASTPASS: "LastPass", + IdPAppType.MS365: "Microsoft 365", + IdPAppType.MSTEAMS: "MS Teams", + IdPAppType.ONEDRIVE: "OneDrive", + IdPAppType.ONEPASSWORD: "1Password", + IdPAppType.OUTLOOK: "Outlook", + IdPAppType.PAGERDUTY: "PagerDuty", + IdPAppType.SAGE: "Sage", + IdPAppType.SALESFORCE: "Salesforce", + IdPAppType.SAP: "SAP", + IdPAppType.SLACK: "Slack", + IdPAppType.VIRTRU: "Virtru", + IdPAppType.ZENDESK: "Zendesk", + IdPAppType.ZOHO: "Zoho", + IdPAppType.ZOOM: "Zoom", +} + + +class IdPAppTokenRequest(TokenRequest): + token_type: Literal[TokenTypes.IDP_APP] = TokenTypes.IDP_APP + app_type: IdPAppType + redirect_url: Optional[str] = None + + class Config: + schema_extra = { + "example": { + "token_type": TokenTypes.IDP_APP, + "memo": "Reminder note when this token is triggered", + "email": "username@domain.com", + "redirect_url": "https://youtube.com", + }, + } + + AnyTokenRequest = Annotated[ Union[ CCTokenRequest, @@ -856,6 +943,7 @@ class CreditCardV2TokenRequest(TokenRequest): SQLServerTokenRequest, KubeconfigTokenRequest, CreditCardV2TokenRequest, + IdPAppTokenRequest, ], Field(discriminator="token_type"), ] @@ -1160,6 +1248,12 @@ class CreditCardV2TokenResponse(TokenResponse): expiry_year: int +class IdPAppTokenResponse(TokenResponse): + token_type: Literal[TokenTypes.IDP_APP] = TokenTypes.IDP_APP + entity_id: str + app_type: IdPAppType + + AnyTokenResponse = Annotated[ Union[ CCTokenResponse, @@ -1200,6 +1294,7 @@ class CreditCardV2TokenResponse(TokenResponse): MsExcelDocumentTokenResponse, KubeconfigTokenResponse, CreditCardV2TokenResponse, + IdPAppTokenResponse, ], Field(discriminator="token_type"), ] @@ -1853,6 +1948,11 @@ class LegacyTokenHit(TokenHit): mail: Optional[SMTPMailField] +class IdPAppTokenHit(TokenHit): + token_type: Literal[TokenTypes.IDP_APP] = TokenTypes.IDP_APP + additional_info: AdditionalInfo = AdditionalInfo() + + AnyTokenHit = Annotated[ Union[ CCTokenHit, @@ -1886,6 +1986,7 @@ class LegacyTokenHit(TokenHit): KubeconfigTokenHit, LegacyTokenHit, CreditCardV2TokenHit, + IdPAppTokenHit, ], Field(discriminator="token_type"), ] @@ -2110,6 +2211,11 @@ class LegacyTokenHistory(TokenHistory[LegacyTokenHit]): hits: List[LegacyTokenHit] = [] +class IdPAppTokenHistory(TokenHistory[IdPAppTokenHit]): + token_type: Literal[TokenTypes.IDP_APP] = TokenTypes.IDP_APP + hits: List[IdPAppTokenHit] = [] + + # AnyTokenHistory is used to type annotate functions that # handle any token history. It makes use of an annotated type # that discriminates on `token_type` so pydantic can parse @@ -2147,6 +2253,7 @@ class LegacyTokenHistory(TokenHistory[LegacyTokenHit]): KubeconfigTokenHistory, LegacyTokenHistory, CreditCardV2TokenHistory, + IdPAppTokenHistory, ], Field(discriminator="token_type"), ] diff --git a/canarytokens/queries.py b/canarytokens/queries.py index 11a10876f..40a59c4b2 100644 --- a/canarytokens/queries.py +++ b/canarytokens/queries.py @@ -374,6 +374,7 @@ def add_additional_info_to_hit(canarytoken, hit_time, additional_info): models.SlowRedirectTokenHit, models.CustomImageTokenHit, models.WebBugTokenHit, + models.IdPAppTokenHit, ), ): info = enriched_hit.additional_info.dict(exclude_unset=True, exclude_none=None) diff --git a/canarytokens/saml.py b/canarytokens/saml.py new file mode 100644 index 000000000..36d3e34d6 --- /dev/null +++ b/canarytokens/saml.py @@ -0,0 +1,22 @@ +import re +from base64 import b64decode +from typing import Optional +from twisted.web.http import Request + +SAML_POST_ARG = b"SAMLResponse" + + +def prepare_request(request: Request) -> Optional[bytes]: + return request.args.pop(SAML_POST_ARG, [None])[0] + + +def extract_identity(saml_request: bytes) -> Optional[str]: + data = b64decode(saml_request).decode() + + SEARCH_PATTERN = re.compile(r"<(saml2:)?NameID[^>]*>(.+)") + result = SEARCH_PATTERN.search(data) + if result is None: + return None + + email: str = result.groups()[1] + return email diff --git a/canarytokens/tokens.py b/canarytokens/tokens.py index 040b17967..22777037c 100644 --- a/canarytokens/tokens.py +++ b/canarytokens/tokens.py @@ -14,6 +14,7 @@ from twisted.web.http import Request from twisted.web.util import redirectTo +from canarytokens.saml import extract_identity, prepare_request from canarytokens.settings import SwitchboardSettings from canarytokens import canarydrop, msreg, queries @@ -655,6 +656,24 @@ def _get_response_for_fast_redirect( return redirectTo(redirect_url.encode(), request) return GIF + @staticmethod + def _get_info_for_idp_app(request: Request): + src_data = {} + saml_request = prepare_request(request) + if saml_request: + identity = extract_identity(saml_request) + src_data["identity"] = identity + http_general_info = Canarytoken._grab_http_general_info(request=request) + return http_general_info, {"src_data": src_data} + + @staticmethod + def _get_response_for_idp_app(canarydrop: canarydrop.Canarydrop, request: Request): + if not canarydrop.redirect_url: + return Canarytoken._get_response_for_web(canarydrop, request) + if not canarydrop.browser_scanner_enabled: + return Canarytoken._get_response_for_fast_redirect(canarydrop, request) + return Canarytoken._get_response_for_slow_redirect(canarydrop, request) + @staticmethod def _get_info_for_slow_redirect(request): http_general_info = Canarytoken._grab_http_general_info(request=request) @@ -700,7 +719,7 @@ def _get_response_for_web( # set-up response template browser_scanner_template_params = { "key": latest_hit_time, - "canarytoken": canarydrop.canarytoken.value, + "canarytoken": canarydrop.canarytoken.value(), "redirect_url": "", } template = get_template_env().get_template("browser_scanner.html") @@ -775,7 +794,7 @@ def _get_response_for_web_image( # set-up response template browser_scanner_template_params = { "key": latest_hit_time, - "canarytoken": canarydrop.canarytoken.value, + "canarytoken": canarydrop.canarytoken.value(), "redirect_url": "", } template = get_template_env().get_template("browser_scanner.html") @@ -848,7 +867,7 @@ def _get_response_for_legacy(canarydrop: canarydrop.Canarydrop, request: Request # set-up response template browser_scanner_template_params = { "key": latest_hit_time, - "canarytoken": canarydrop.canarytoken.value, + "canarytoken": canarydrop.canarytoken.value(), "redirect_url": "", } template = get_template_env().get_template("browser_scanner.html") diff --git a/frontend/app.py b/frontend/app.py index fbd239b1e..38fe5ee94 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -126,6 +126,8 @@ PWATokenResponse, QRCodeTokenRequest, QRCodeTokenResponse, + IdPAppTokenRequest, + IdPAppTokenResponse, response_error, SettingsResponse, SlowRedirectTokenRequest, @@ -834,12 +836,17 @@ async def api_generate( # noqa: C901 # gen is large else None, ) + page = None + if token_request_details.token_type == TokenTypes.PWA: + page = "index.html" + elif token_request_details.token_type == TokenTypes.IDP_APP: + page = "saml/sso" + # add generate random hostname an token canarydrop.get_url( canary_domains=[canary_http_channel], - page="index.html" - if token_request_details.token_type == TokenTypes.PWA - else None, + page=page, + use_path_elements=(token_request_details.token_type != TokenTypes.IDP_APP), ) if token_request_details.token_type == TokenTypes.PWA: canarydrop.generated_url = canarydrop.generated_url.replace( @@ -2060,10 +2067,7 @@ def _(token_request_details: MySQLTokenRequest, canarydrop: Canarydrop): def _( token_request_details: CreditCardV2TokenRequest, canarydrop: Canarydrop ) -> CreditCardV2TokenResponse: - canarytoken = Canarytoken() - canarydrop.canarytoken = canarytoken - - (status, card) = credit_card_infra.create_card(canarytoken.value()) + (status, card) = credit_card_infra.create_card(canarydrop.canarytoken.value()) if status == credit_card_infra.Status.SUCCESS: canarydrop.cc_v2_card_id = card.card_id @@ -2096,3 +2100,26 @@ def _( expiry_month=canarydrop.cc_v2_expiry_month, expiry_year=canarydrop.cc_v2_expiry_year, ) + + +@create_response.register +def _( + token_request_details: IdPAppTokenRequest, canarydrop: Canarydrop +) -> IdPAppTokenResponse: + canarydrop.idp_app_entity_id = canarydrop.generated_url.removesuffix("/saml/sso") + canarydrop.idp_app_type = token_request_details.app_type + if not canarydrop.redirect_url: + canarydrop.browser_scanner_enabled = True + save_canarydrop(canarydrop) + + return IdPAppTokenResponse( + email=canarydrop.alert_email_recipient or "", + webhook_url=canarydrop.alert_webhook_url or "", + token=canarydrop.canarytoken.value(), + token_url=canarydrop.generated_url, + auth_token=canarydrop.auth, + hostname=canarydrop.generated_hostname, + url_components=list(canarydrop.get_url_components()), + entity_id=canarydrop.idp_app_entity_id, + app_type=canarydrop.idp_app_type, + ) diff --git a/templates/emails/_generated_dont_edit_notification.html b/templates/emails/_generated_dont_edit_notification.html index 56c1bcc1a..e44e8076b 100644 --- a/templates/emails/_generated_dont_edit_notification.html +++ b/templates/emails/_generated_dont_edit_notification.html @@ -399,7 +399,63 @@ - {% if BasicDetails['useragent'] %} + {% if BasicDetails['identity'] %} +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + +
+ + +

Identity

+

+ {{BasicDetails['identity']| e}} +

+
+
+
+
+ +
+
+ +
+
+ {% endif %}{% if BasicDetails['useragent'] %}
diff --git a/templates/emails/notification.mjml b/templates/emails/notification.mjml index b2873c384..eda1aa769 100644 --- a/templates/emails/notification.mjml +++ b/templates/emails/notification.mjml @@ -146,6 +146,31 @@ + + {% if BasicDetails['identity'] %} + + + + + + + + + + + + + + + + {% endif %}{% if BasicDetails['useragent'] %} @@ -208,6 +233,7 @@

{% endif %} + diff --git a/templates/static/notification-email/canarytoken-icons/idp_app.png b/templates/static/notification-email/canarytoken-icons/idp_app.png new file mode 100644 index 0000000000000000000000000000000000000000..d42dae26c55278d76f3d0e692c111cbddb91c982 GIT binary patch literal 20969 zcmd42WmH^E(>6LlAUFhf*WfO}3GVI$cV}=XxD(uhL(t%E3GRbifZ!H_2ZyuCeLv6l z9{cs3ABVN}nmwEDuCD56mtab+2CaWZ8WM+<8^a}Y>+FelYRU3~#3e6rKSJAa&Y8s9sKnKkf|nT+_A3Z4ZV z-Uz4{S-~#ii^%&oZ=fU+@&cg>^Q9{zOvvfJ7@Nb3NF( zzHE8odcEY6+sF)}QX*w$flY-b%b@-F+M9PqD^vjiC%t28yKox~A z8JWQ%tK8>e5~sIaoa=|Pq%!u)j6|<*Gh1zG*pBTw!qX=#fzqT;st*=lU$G>@Fu_&ARBQ(7+e-M8YG?~XUTGlQ(h)TlG zw)?E#%WBgr5*J?8J|>ofgCn+uC^fMqulp!~{{hACOpx--y*n-Z_ei8UV$v(H^Qtzz z4853;pkT`ICIsbKpzoRY^y`H-!BFJRDW8DK`!7}_Egifn?Ot}(fmz=RUA>;mBA;1* zDd}e0CemHyg1AQDrC0NyxA%ks3(%o3yp(@jt>5o{M1$ST!Y$#i<-N{@eY~%Bio8jj zh`lG;!@!fbM7YI|w%xQvfCPf5ps z(bz?q7m*P~-YmlY36@ocITWR|f}$09&5ObuIQo;n1>tw#Yok{&ANP#lI-oAlUm26L zzYp3b*N{eH4;&+dGec_?6;dX{LX{AAMn}UCYlYx^fqxtNHedJ4n>!qSScAZ7F&K!T z6;@`jf9P5-ynJw34SOXDVK4F`!k@5@8(dCA*>HP3x2K9N(A6K~za5{#-tharCJD;z zt%nzhfJadgM}7mh8GJ+jMw;=v6gOF@G@ojC1DSy|iwhFIXi1_69dcA~K_RkA6P=Pe zd{n5cxV;E=;ae3cmE$iYvrMyeHgH!63X%-@C=hNdmdm$bB7RJRKxQePycI|q!~?Rm z&x3~?7B=-w+gN~YBmLDFzdYM6s07=Ugd-5Ycg&<#+p7|49`z{P+yr(J`j`JNwqJa| z$lQtUaisdnHpiTPx4;vjrG+yLa&1g(!Z~BMcy(YIA~}A%?yWyty20=j>_Q5F5su5H zmBs|ayN4KwK^t@A2SdbK-g`=WN>E|UMb7tuDAHOYwM7LKJH&{nyC{bfE@haDq(3Dz zB!NS~pTY5$QSvmlNyNi%%%HqzTN8Z~AJQK*gDH%uHmJX)F4Cz`PcgI6orz(vlu;j0 zwQ90U)#;xXZKDiZ(|05{B}Zea*?d$_nWZl?F4M7&YA9>S1=oRV!AX}I2Qv7TF-l3>JsG==R(vf)~eb1&gEq1;N0On z^1OZL-8LzG9@Zr`G!{EH>RWx)bj_l-%Wvzjkg%2L*0h3CE>oaW=IMzw57ja?G*$j6 zD{8EL^;PlG4K3OH@+nWX#6{CwHl@Jk>xc%EnqcW&46~d^0qqpSzThQ37d4j>SH5nQ zZufFWTby@<_h;{lJ4%#bIB`nNLis{HQ%NQbjWnb`E4&4Ln6+iauT?v;HSMVCT!R(`y2*kXJp;O z#cRY`gVj0fP48&EI&CceDL=_L%(#Bz%bW3`LAuE=z5AiHCC(vd4qJv>N;Eh$GBj5- zX!7RrTIstP?io)uC5{8^73}TyirgMN>sD*lPc9kX*eB{fj{eqFX7puh(gExFHZ;_2 z>osXB>R9Va=xOMoaC|`ykYwA6;#yu_@-DoxKJwsB<%Z6}uok^jXXj*ZU;{IcaaD5KnWdbSUsY6x zGlaPIzN4&plm7PDKaPlZWJXJ(#}9iOpMk}H#g)IZJ!&n9waHFR2Y_&WNF z^Cx%FbgGG9KgwSrxP|J47UKWG*T8q=5a&>`kg+_gyQ{mZ_b^l1ZrStgL~V>~tbV7( z@fMwM~^~`I?HLN1&h4Y2MbiRDzw%g3Rmimhj*6-m^^$)(a`514I+N4-G;M zUDvh)3$ZAOC=w{7@uzVyL?{BwGgfnpi$$~Q)5ttXS*39jB<;7cM?5)9VN8ux4f6$C zKU+?3>KzNaiTOAD3ht@4%tk$Q*EO0|mJwI-|NQ!s5qA@pIB_}=q{g7==CS17=;wI( zm^fooS}`&7nVwIMH!Hw>Lw}-m>7s0}l%>xA?%7|K3B%J%ytz1DMg*6R7;ihAlhZ zN9XomR~AkxJ2+Ue*ufsWE}q-W_rJG0i+os4EPvz%)EtoC8E;UQN$td^kSsiYxi&qD z*cr-DS(xgg6;&dMsU7(IocuHse)dqhUAlu=jv1R|kyKjxqtro2@6qnT>+s_CAUL-; zpxQtGezW}pQNTf#4j0GMr)QUqg-xyVuW=oQf7*QJ1OA+4ZS!2XhC1%7llTpKF5C&^6+H=%W+FIA>$rkI%#<$|lxMN9I|u~h zXsxad)>e?`H+8gUHa2rKF=zI)cLG`id8e?ald-9-Ihf4E+|t@XkoKg#o0iPlOpsQK zOMz9vN!;AZTH4#iT+Lfi-PGIGl+TP-SO^7Cz>^;s!QLEfOy+5C=itikDM;~mV1D5K zX6nNa*I-pusB(w*E~?EcC&Gi5QiGq*Q)0K2lVF|)nQ z+02yR)WzHw?CA2({MF2z|M%u^XDuXVXKZUurr_x4V&&-OYA(R?q6hH&pBq2|U{C}E z1Ob-+Dg1Av|DpUp)A=6{{Q&dUr+$>V0rluU<}q5W4zq+zc&V(t6STf zE4f$$05P@`VrOON1dPqZ&Zf@B!_UUe&&tcp&dVwAx9k7C`9E6zkBR?(s|f(Z|1RwR zNfA$Dr~l{ywBZ+jSyl5FW&Yn4_`kIKf0+5-RRJ&!tlfV-;Kd#O>qX`cfa`bwKC%Tr zq8=y>Ed{k?7s9kLlXt^#I$~>f^L+F8`eV z6`xj_UxY01D2oGqi$ierR@!5zVq)-A5dK2*KL?G69RXl1w>dS9RgIa|!o%vl``ZVL zwp`c4>_gYXhr8Kn4KZ1SFynN(0tl#=RbJf)#te_9t_F%78B=EwgdY~kXcvgw`yPi| zB+QrsiIA+9H8B(=!8m>BJKPYfyqWR;Uz>*{e;S||CieI-wYIYIF7N0L6bJ=E!j{-z zfiF3jax-}C1l>a}p@JG48%x}FyPIWc-9?8X1M{QKd^E6tZUK>et%`0WLv;j--u(6W zAZt|=x``ybv-HB1f$B)^L7V>x3eOLxh+SJZ3e!N3HLtylTJ2wtQ3d*?M|ZHG_it0g zw6t(P4O6L>B_9V+B<4x5%Flcn-ANd?NE|jBd(M!Do0*vzGGhmkVM}=FE?oH_hJKG1 zzWIjsyTH7Gg2_HGi^oxO?N3v)9~DltpW(vQ3@ER@9`o_>asT9GFc=nTp8^?^Z(wkc zjh#I>G4YMOk`fFEOTw#4naCoNVdg?rzc_ zg$)cMCH4B`fIS8mC};8T@xKMZyk->?L?Q1R4Hetq0Cekx;dEqxxWMspa$dTFK0uvL zsJ{($JDN8Jx}%FhDv^6V$%^}cZZ(#ZB&N8xsMM*4D=(i0dfmLA;rzAQj_Y&%TOwZ>6c_+=dhqSrH_q{un1&E2R1_2@T(Jjr zS+A#&6b9$JgVGBbxQ2KfhETo!}D@`9|$p(_}7#!o$LZ7(;FpDH0=c1DKe-(nBpenZDw%-<~J6MeVUEi_L&UDq5$;a21 zXj1T+$fZ@^Q1*5TmLWVcv;pgyx!Qluq2 zzQFyj`ta9>*!=C+pI?#{B7>;ix96kB@ z`OzWh@osKz{VZKKCfvd$)p+$K`pn^A{$p!9G58BDak}y4a^vKxBYq1Di?^9d0wm1_ z^}1EQM|O_x?l`Ub6R8do8X67F%~@K~>J04|fRJI@+`idabwgP%oqA$SySINmQbd3x zK!_xsrS)=JZag;L_%O)%fhl+LeyuUK-vpR{(^iNB-r#zSfu-=4B4FJlnG0=W>KId> zCA;~B7!`Qq-3o#MoXj^@=8Brg7t%CIpJ~#^Ey(3)`GSp-?eVGzL&czOeM!=L+X!i8Y;xp* zzn?taw?EK|dht1fB_Aby<>r4I31$TEP^OZ$t zl7gy_;>;^#;Gq;|-u{l8oR|=sY2d4BuRhwf{2KcCGsz2_RY#g(Zg4Y6G&8@0PTG#D zQN$wRtMpqjMpKyzhmqAd>g(%wBO_Xebm6FT6age<(P{CP+Sl))@!oMIIEu#Zau1Q=Z0LePGiA+p&L0aFE=?}ZcHdX%FwR#9!~F6afPR- zkeNESw2ZzcDIUGi5Fn8f%H^>P(!{s)t{No&h=_Ahtx@{f`>nc0XnFYu@?PHLkTp=V z!|L5T&|0hamCchE;S73=sa1J7|Jb*$>Ka!9UZ2J-f(Ve6>5t33$3a{Q71pbv=)3&$ zvJ{CuhjSI=#U16G?&1nxP@FDy$D@Ej6=;&QJH5FSEYjP^ia9MEW{Dm~bm970CEMiD z@g=3EIIBtwj3iXXkP46bfdOJM#hM-0I{0QuFb`Aoy8Rzx+p52QXm@8yo|YUinbVUU zHnL&+1fWx&d9O96WX5`s-9L2N`MvS2OcuH6pAzd#Q@(DOmBwm`r~&^6#DWEw zJwdf?ZEPVzK}qRd>IG%8L-3)dI}}JZ2!RnJj*l>i(XeBY zt>2>duqqS@S4=}=%ijaF@JrF-{WS~-he>yvPD>VHYeTQFurLVa&tk1579b{m);aQ@Q)LPwC$#4;jzjpMqIyrN=3clY{58Ud&Ve4t7f6)foU=SFCrS4b!h zme(m#J5D3FMT zEG$UX^GcV_?Akf+jHCc@(L=C9jSj0O;MqSWCWb6U)_AU5Ln8pG@`(S>$$C6Mu8cZ$ zuPQXF&N;WIrc^qBFbi=bX3(m(+o#&PTKF*7<#%t!on_#^70W|XazLAnX)z!>>Th14I zU{h1mA&YwHVpTjmJgb|=#-yYqPav3*7e8zRlYF+%^shrGeu=iLS@JaN?bb6inSHDm z!PQ5A1P-MbZ5f)R{lh~sM@PrGj0{|0x&Rt`#>S80r-_EKB9|WNC^73-*sL$m7&oM9oVPYbpj?|JIJ2C4(AXH6Gz#`#fdjaXv6jfM; z$K|wwl1dvx^rrJ*S}R%c`+ViV>Z6E$6IX6^7ET2YmhW->CS8_ESKGIHxq~-1H`M@Z z{kG>kHdE{VT*)$u85y!wi4pC_PBmn|YwgVh-TEk`@KpoxNPt&CZlg2;TnJwZy%;_^T>iIGX&zy++w!fh(_hGzRz(Y zbI@Xe1lfX~T6(67g3Zf#m*;NViD+^Zx5`RC2PdD;EPM!frUgKrTC6qt(?otN$DpUUlfI8&Kin=5iWT|vI}(ds1e66hGC(-{+hZs*Q9SnO z0tY{P66`)70@GLVNtt~wgpf*mJPSp5k-k^6Ezs1=d41Sa{2G1jQMd-p2e5X@TQ?U% z*m7$9N3cgS{5jkt8N+bev6A?CAw34+mS^3rf_PdNtMb4o=KI@Y1`Wb$_qW`r$Sb zmN&v(zBA)nZCXj9U?RiswK{D^5m_t?M7#huf~~I;M(m}Vq;-BRk5zX93{+zw%OKmG ztk>)}CQWD=9X)3`9MbU?(Zmz1F$}H4(2BpZCEYs$Nh5_GcDkS~8s~{Qm}~=|Pc?Cb zMMzyRVTsn+P?)NqFaRt>nTeWgHsX&FjchJ0iai|o!}7^UY1^Xf!W7NylP!Bw#pXs) z4-rk8J~ZkK;XJ{arc1K9vVI))v;L*ge(K@4OT5Jx@m3tl*i0R~yeo(*F{Mq#ex|SQZ78Sde8@r&m ziv%!pkeH;Dv8K4OX4VWjc|IbeP$}kUKcjz(?#IAqcJ>V9&tALuA!qzOscb!u2hdOZ zi(+Fjw&_1O#FH34{faF#C5zO~LgYN96-CB`yR3{`GW8lGL3P)7ulE7NphE4Y;s*Yy zX8n253QQjk@54{m6vmU1Om9qPTWR?NcHu(7eXglJ~q`QpRXLdy!M-@ zKmtr<6X@FXKmwSChPX2xi)bzB8&P;hp>|9pQ!nJI39=2>I&=#ddn^97Zz1LdsOt zn*B<}hHF~ltzM0`zS$?_fjO$*LZ6&tdSwwA^0|7*?B1z7z{!!0Pf=;j76rqWdVr#O z?1pdhE)Xy^!RkZ%=>!YPbf6ptHEHJaYxt;N+E8_5XI4#I4%$C3;BMY`p?GUb84pbU zQp)pDc=sysKn8_i{W3n1PBl$h$tD3d&(e)Bxn+~x@Cy|?E#e}*H>&}}I#EH&3MD}? zzZ1+@vEkN8n86lc>9=x+8R1bbJ-MF%`}wN2fH`uB9z{9ux7x$1AN{+};WV#E1U|dQ zNU?;bKnU_akl>Gs%aXlIEtNq{ZV9f&rnmBn^pz~sgO0|QImnFrRT=l*19^DuFuq_& z0SBb8ISjt)FfKhnId$std|S?1!S~xq6#GaS1Z8IF)`n7}ov6ud@FOSIqn~JNfLtLw zb&l=rQNYFQElmTQbScD;zZy4QPnvX)a`U(!b!*EXSv:%i3Cb%J3KE2g;l>Or`T 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 Hu0mjfI=6;J literal 0 HcmV?d00001 diff --git a/tests/data/sample_saml_data.txt b/tests/data/sample_saml_data.txt new file mode 100644 index 000000000..b4ff4ca2f --- /dev/null +++ b/tests/data/sample_saml_data.txt @@ -0,0 +1 @@ +PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOlJlc3BvbnNlIERlc3RpbmF0aW9uPSJodHRwczovL3Njb3JwaW9uLWd1aWRlZC1iZWFyLm5ncm9rLWZyZWUuYXBwOjgwODMvb2V5N3dkaHkyeWlsN2xhZWJxMHptMzJ2eC9zYW1sL3NzbyIgSUQ9ImlkNjQxMDMwODU2NDM0NDY3MTk3NTU5MjgxOCIgSXNzdWVJbnN0YW50PSIyMDI0LTExLTA0VDEyOjA1OjU5Ljc1NloiIFZlcnNpb249IjIuMCIgeG1sbnM6c2FtbDJwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiPjxzYW1sMjpJc3N1ZXIgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bmFtZWlkLWZvcm1hdDplbnRpdHkiIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5odHRwOi8vd3d3Lm9rdGEuY29tL2V4a2wwdHh2bGNBb3RQZGJ5Njk3PC9zYW1sMjpJc3N1ZXI+PGRzOlNpZ25hdHVyZSB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+PGRzOlNpZ25lZEluZm8+PGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz48ZHM6U2lnbmF0dXJlTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxkc2lnLW1vcmUjcnNhLXNoYTI1NiIvPjxkczpSZWZlcmVuY2UgVVJJPSIjaWQ2NDEwMzA4NTY0MzQ0NjcxOTc1NTkyODE4Ij48ZHM6VHJhbnNmb3Jtcz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PC9kczpUcmFuc2Zvcm1zPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNzaGEyNTYiLz48ZHM6RGlnZXN0VmFsdWU+S1Y4a0R1VjlrU1d4SmdXdmNZY3RkVGxnY0ZiUkVJVEJXNG9rTlA1bGw5TT08L2RzOkRpZ2VzdFZhbHVlPjwvZHM6UmVmZXJlbmNlPjwvZHM6U2lnbmVkSW5mbz48ZHM6U2lnbmF0dXJlVmFsdWU+WDRCR3g0OS9mR2hVT21Eb3crVDJ1cmhKK1d6YUdFbUQvZmt1cklxRGJrZ0pSK0x5KzJ1Rm9ZaEd3NFlwNVVrMndaY0VTcjhOR0pzMkNzSkJhTnpldGU0L05hT1dmV3lmWEZ5Q3FYcXZlZ2szSS9HSGU3blhIVDhJNjdFb2owUEt3cnNyT0lRK1NGNU0zQVRiTEI2R1pmcVdqNWJRRWhnU0hKS2VWb3lCQ294SThqYklQVGN3aVJpVkRKZHpGR1dHYVpDSkRadFZwSFA2K3Y4QVBLQ2xGTnROajBkUGNKR0pnV1Z3dHVJbXM5M3BPV0p2d2prajF3eU5hQzliazJlQWc0YVhPaDRXQWk4dVJvVmJ5VUZ6RmdSS2U1bU4rdDlqcGcrY1h4OTBURGJiaFkraTdGSDNlWW9uenl1dnZtaCt3K09DRE1oaVZMQzJUd2ZJYTR6TFdRPT08L2RzOlNpZ25hdHVyZVZhbHVlPjxkczpLZXlJbmZvPjxkczpYNTA5RGF0YT48ZHM6WDUwOUNlcnRpZmljYXRlPk1JSURxakNDQXBLZ0F3SUJBZ0lHQVpMM0JZaUZNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1JR1ZNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUcKQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVXTUJRR0ExVUVCd3dOVTJGdUlFWnlZVzVqYVhOamJ6RU5NQXNHQTFVRUNnd0VUMnQwWVRFVQpNQklHQTFVRUN3d0xVMU5QVUhKdmRtbGtaWEl4RmpBVUJnTlZCQU1NRFhSeWFXRnNMVGMyTWpJNE9EVXhIREFhQmdrcWhraUc5dzBCCkNRRVdEV2x1Wm05QWIydDBZUzVqYjIwd0hoY05NalF4TVRBME1URTFNakEzV2hjTk16UXhNVEEwTVRFMU16QTNXakNCbFRFTE1Ba0cKQTFVRUJoTUNWVk14RXpBUkJnTlZCQWdNQ2tOaGJHbG1iM0p1YVdFeEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4RFRBTApCZ05WQkFvTUJFOXJkR0V4RkRBU0JnTlZCQXNNQzFOVFQxQnliM1pwWkdWeU1SWXdGQVlEVlFRRERBMTBjbWxoYkMwM05qSXlPRGcxCk1Sd3dHZ1lKS29aSWh2Y05BUWtCRmcxcGJtWnZRRzlyZEdFdVkyOXRNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUIKQ2dLQ0FRRUFseEY3YmxjUXNUem1TY3lRSzdUOVpVVkcwV1ZWeFAzNXZCVzg4dFRtYVBOajlwZTJOR3VOd2ZlTEFGZ2N3T2NtY1I0RwpUNWFPaFEzYkZnL0hEYWxwZXBaZUg5ZDFjaEg5eThWbHcrRS9odDRSdWprVWtLSmc3R1RLOHdlM1ZPeGN5enovblUwRkNhejBNVjBRCjhmTE14M0djOEZzbUlHcVczczk4dmpnUVVpRm96NmNRaXJEV2gxRVAra3VCdkpRbEd4NEp3VGM0U3ZpZk5qajlabHplUkNZYlZPU3QKT2RpVXRjclQ0VExvOG1SbWV6WjFXK3cxalF2VFlDZGdQaEhWdHlCRTJhSW1HVE91a2pROHBRRkJyNVc1MGFKYnBoODE5bldLSjZxdApMWldKaWRkVWpaMVlBek5PM2dZL0ozby9HNFJIRWRaM05rbHNXaER6dzQzRldRSURBUUFCTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCCkFRQ05TMng4VHNFQmFCeVFFVWZrWW5pclhEdWQ0L05iajN3Vlc5cFZMM0Zzem13Zm1MbzRaNEV3bU9La2FrRUNGSmRnV2hTMUtIT1MKQnRWTktDWTZuTmZwRmVDNmdORlE3SlhzMEh0bUpUY2lBblFOZTJHVTRyeU1rcW8wNzllbzFkWVJGOVVPMys5VGRETTFlclk3YjJLaApkZ1NOdW9jRFlpWU9PK0pjZG9QSWJWQ09ma2dZMUV0UThhWWVlaEVSQ1FGWWNwU3dQM08vM2hxVEtuOXlSdFhid2EzMnZiSzNtL0RoCkMxMFQzTEU3QXJLbXhXZkcvV0h1TDJ2QWNHTFlFdkRyRUp5THJiRmxURVptOWV2Vll3UmJZTTZDeWN6SU9UYjh2T2VwWStyR2g1UFkKL01IOURFaXFxYnBtWFFrdWxlU0NtSXF2NnNEWXBaVFFwQmg2dk90MzwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1sMnA6U3RhdHVzIHhtbG5zOnNhbWwycD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIj48c2FtbDJwOlN0YXR1c0NvZGUgVmFsdWU9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpzdGF0dXM6U3VjY2VzcyIvPjwvc2FtbDJwOlN0YXR1cz48c2FtbDI6QXNzZXJ0aW9uIElEPSJpZDY0MTAzMDg1NjYxNjIyMDEwNjY5NDE2ODAiIElzc3VlSW5zdGFudD0iMjAyNC0xMS0wNFQxMjowNTo1OS43NTZaIiBWZXJzaW9uPSIyLjAiIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj48c2FtbDI6SXNzdWVyIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5IiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI+aHR0cDovL3d3dy5va3RhLmNvbS9leGtsMHR4dmxjQW90UGRieTY5Nzwvc2FtbDI6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPjxkczpTaWduZWRJbmZvPjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZHNpZy1tb3JlI3JzYS1zaGEyNTYiLz48ZHM6UmVmZXJlbmNlIFVSST0iI2lkNjQxMDMwODU2NjE2MjIwMTA2Njk0MTY4MCI+PGRzOlRyYW5zZm9ybXM+PGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+PGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjwvZHM6VHJhbnNmb3Jtcz48ZHM6RGlnZXN0TWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjc2hhMjU2Ii8+PGRzOkRpZ2VzdFZhbHVlPmtTRnhJSGtnQWo5TEhoWU01c0xqYk9IVzBXZ2pSZEY1K1dTSUZJY3MxOFk9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPkEvNmx1K0pxQTJNb3ZlS3I4REcrZVh0NDg5aFZQODl6WWJFdWREaTRhTWZDa2xSV2p6aXhyMXd5bGhiT1BlVDVwTUJZRExKSzhHVUJBKytZWDU3VjBjMXNmbTFjUk1IK2RmdmxEcEF5ZEJHemFEekhPWHJUTlZldjJEMFdoMi85S1J3U3NsblhzeVBaTHhNR2s5N09ncTdWNmNaZHhsbmZpSTBTaW5NZnk5bHQrdzliTXVMdzQva1BRclJ5cStFbHBJdVNpZVozZi9pRnVRc1E0dlVseDZGRVgzVk9nd1lNb2pSWUxTSEhjazBid1gvTWZOMmhOT3Nrb1dpQyt4MGxraEd4cjNwVDB5bC9OM2EySGdxREVocy9zQjhIbTMzYkxTMzNuNDR2aGdzL1hVaUJrZ0o5VUgzaXZwdFo4aUZPTS9tWUtsdnRxOFhPM3AybHpJMzdlZz09PC9kczpTaWduYXR1cmVWYWx1ZT48ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE+PGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlEcWpDQ0FwS2dBd0lCQWdJR0FaTDNCWWlGTUEwR0NTcUdTSWIzRFFFQkN3VUFNSUdWTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHCkExVUVDQXdLUTJGc2FXWnZjbTVwWVRFV01CUUdBMVVFQnd3TlUyRnVJRVp5WVc1amFYTmpiekVOTUFzR0ExVUVDZ3dFVDJ0MFlURVUKTUJJR0ExVUVDd3dMVTFOUFVISnZkbWxrWlhJeEZqQVVCZ05WQkFNTURYUnlhV0ZzTFRjMk1qSTRPRFV4SERBYUJna3Foa2lHOXcwQgpDUUVXRFdsdVptOUFiMnQwWVM1amIyMHdIaGNOTWpReE1UQTBNVEUxTWpBM1doY05NelF4TVRBME1URTFNekEzV2pDQmxURUxNQWtHCkExVUVCaE1DVlZNeEV6QVJCZ05WQkFnTUNrTmhiR2xtYjNKdWFXRXhGakFVQmdOVkJBY01EVk5oYmlCR2NtRnVZMmx6WTI4eERUQUwKQmdOVkJBb01CRTlyZEdFeEZEQVNCZ05WQkFzTUMxTlRUMUJ5YjNacFpHVnlNUll3RkFZRFZRUUREQTEwY21saGJDMDNOakl5T0RnMQpNUnd3R2dZSktvWklodmNOQVFrQkZnMXBibVp2UUc5cmRHRXVZMjl0TUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCCkNnS0NBUUVBbHhGN2JsY1FzVHptU2N5UUs3VDlaVVZHMFdWVnhQMzV2Qlc4OHRUbWFQTmo5cGUyTkd1TndmZUxBRmdjd09jbWNSNEcKVDVhT2hRM2JGZy9IRGFscGVwWmVIOWQxY2hIOXk4Vmx3K0UvaHQ0UnVqa1VrS0pnN0dUSzh3ZTNWT3hjeXp6L25VMEZDYXowTVYwUQo4ZkxNeDNHYzhGc21JR3FXM3M5OHZqZ1FVaUZvejZjUWlyRFdoMUVQK2t1QnZKUWxHeDRKd1RjNFN2aWZOamo5Wmx6ZVJDWWJWT1N0Ck9kaVV0Y3JUNFRMbzhtUm1leloxVyt3MWpRdlRZQ2RnUGhIVnR5QkUyYUltR1RPdWtqUThwUUZCcjVXNTBhSmJwaDgxOW5XS0o2cXQKTFpXSmlkZFVqWjFZQXpOTzNnWS9KM28vRzRSSEVkWjNOa2xzV2hEenc0M0ZXUUlEQVFBQk1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQgpBUUNOUzJ4OFRzRUJhQnlRRVVma1luaXJYRHVkNC9OYmozd1ZXOXBWTDNGc3ptd2ZtTG80WjRFd21PS2tha0VDRkpkZ1doUzFLSE9TCkJ0Vk5LQ1k2bk5mcEZlQzZnTkZRN0pYczBIdG1KVGNpQW5RTmUyR1U0cnlNa3FvMDc5ZW8xZFlSRjlVTzMrOVRkRE0xZXJZN2IyS2gKZGdTTnVvY0RZaVlPTytKY2RvUEliVkNPZmtnWTFFdFE4YVllZWhFUkNRRlljcFN3UDNPLzNocVRLbjl5UnRYYndhMzJ2YkszbS9EaApDMTBUM0xFN0FyS214V2ZHL1dIdUwydkFjR0xZRXZEckVKeUxyYkZsVEVabTlldlZZd1JiWU02Q3ljeklPVGI4dk9lcFkrckdoNVBZCi9NSDlERWlxcWJwbVhRa3VsZVNDbUlxdjZzRFlwWlRRcEJoNnZPdDM8L2RzOlg1MDlDZXJ0aWZpY2F0ZT48L2RzOlg1MDlEYXRhPjwvZHM6S2V5SW5mbz48L2RzOlNpZ25hdHVyZT48c2FtbDI6U3ViamVjdCB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI+PHNhbWwyOk5hbWVJRCBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjEuMTpuYW1laWQtZm9ybWF0OnVuc3BlY2lmaWVkIj50b2tlbnMtdGVzdGluZ0B0aGlua3N0LmNvbTwvc2FtbDI6TmFtZUlEPjxzYW1sMjpTdWJqZWN0Q29uZmlybWF0aW9uIE1ldGhvZD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmNtOmJlYXJlciI+PHNhbWwyOlN1YmplY3RDb25maXJtYXRpb25EYXRhIE5vdE9uT3JBZnRlcj0iMjAyNC0xMS0wNFQxMjoxMDo1OS43NTZaIiBSZWNpcGllbnQ9Imh0dHBzOi8vc2NvcnBpb24tZ3VpZGVkLWJlYXIubmdyb2stZnJlZS5hcHA6ODA4My9vZXk3d2RoeTJ5aWw3bGFlYnEwem0zMnZ4L3NhbWwvc3NvIi8+PC9zYW1sMjpTdWJqZWN0Q29uZmlybWF0aW9uPjwvc2FtbDI6U3ViamVjdD48c2FtbDI6Q29uZGl0aW9ucyBOb3RCZWZvcmU9IjIwMjQtMTEtMDRUMTI6MDA6NTkuNzU2WiIgTm90T25PckFmdGVyPSIyMDI0LTExLTA0VDEyOjEwOjU5Ljc1NloiIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj48c2FtbDI6QXVkaWVuY2VSZXN0cmljdGlvbj48c2FtbDI6QXVkaWVuY2U+aHR0cHM6Ly9zY29ycGlvbi1ndWlkZWQtYmVhci5uZ3Jvay1mcmVlLmFwcDo4MDgzL29leTd3ZGh5MnlpbDdsYWVicTB6bTMydng8L3NhbWwyOkF1ZGllbmNlPjwvc2FtbDI6QXVkaWVuY2VSZXN0cmljdGlvbj48L3NhbWwyOkNvbmRpdGlvbnM+PHNhbWwyOkF1dGhuU3RhdGVtZW50IEF1dGhuSW5zdGFudD0iMjAyNC0xMS0wNFQxMjowNTo1OC45MzFaIiBTZXNzaW9uSW5kZXg9ImlkMTczMDcyMTk0MzUxOS4yODg2MzA5MzMiIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj48c2FtbDI6QXV0aG5Db250ZXh0PjxzYW1sMjpBdXRobkNvbnRleHRDbGFzc1JlZj51cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YWM6Y2xhc3NlczpQYXNzd29yZFByb3RlY3RlZFRyYW5zcG9ydDwvc2FtbDI6QXV0aG5Db250ZXh0Q2xhc3NSZWY+PC9zYW1sMjpBdXRobkNvbnRleHQ+PC9zYW1sMjpBdXRoblN0YXRlbWVudD48L3NhbWwyOkFzc2VydGlvbj48L3NhbWwycDpSZXNwb25zZT4= diff --git a/tests/integration/test_saml_token.py b/tests/integration/test_saml_token.py new file mode 100644 index 000000000..5763acd32 --- /dev/null +++ b/tests/integration/test_saml_token.py @@ -0,0 +1,61 @@ +import pytest +import requests +from http.client import OK + +from canarytokens.models import ( + Memo, + TokenTypes, + IdPAppType, + IdPAppTokenHistory, + IdPAppTokenRequest, + IdPAppTokenResponse, +) +from canarytokens.webhook_formatting import TokenAlertDetailGeneric +from tests.utils import ( + create_token, + get_stats_from_webhook, + get_token_history, + v3, +) + + +@pytest.mark.parametrize( + "redirect_url", + ["https://canary.tools", None], +) +def test_saml_token(redirect_url, webhook_receiver, version=v3): + memo = "SAML memo!" + token_request = IdPAppTokenRequest( + token_type=TokenTypes.IDP_APP, + webhook_url=webhook_receiver, + memo=Memo(memo), + redirect_url=redirect_url, + app_type=IdPAppType.AWS, + ) + resp = create_token(token_request=token_request, version=version) + token_info = IdPAppTokenResponse(**resp) + login_url = token_info.token_url + entity_id = token_info.entity_id + assert login_url.startswith(entity_id) + + with open("data/sample_saml_data.txt") as f: + raw_text = f.read() + data = {"SAMLResponse": [raw_text]} + + # Fire the token + resp = requests.post( + login_url, data, headers={"Accept": "text/html"}, timeout=(30, 30) + ) + assert resp.status_code == OK + + # Check that the returned history has a single hit + stats = get_stats_from_webhook(webhook_receiver, token=token_info.token) + if stats: + assert len(stats) == 1 + assert stats[0]["memo"] == memo + TokenAlertDetailGeneric(**stats[0]) + + resp = get_token_history(token_info=token_info, version=version) + token_history = IdPAppTokenHistory(**resp) + assert len(token_history.hits) == 1 + assert token_history.hits[0].src_data["identity"] == "tokens-testing@thinkst.com" diff --git a/tests/units/test_frontend.py b/tests/units/test_frontend.py index 4444c9d30..94b4bc683 100644 --- a/tests/units/test_frontend.py +++ b/tests/units/test_frontend.py @@ -162,7 +162,6 @@ def test_generate_log4shell_token(test_client: TestClient) -> None: CustomBinaryTokenRequest, PWATokenRequest, CreditCardV2TokenRequest, - WebDavTokenRequest, ] set_of_unsupported_response_classes = [ AWSKeyTokenResponse, @@ -172,7 +171,6 @@ def test_generate_log4shell_token(test_client: TestClient) -> None: CustomBinaryTokenResponse, PWATokenResponse, CreditCardV2TokenResponse, - WebDavTokenResponse, ] [set_of_response_classes.remove(o) for o in set_of_unsupported_response_classes] diff --git a/tests/units/test_saml.py b/tests/units/test_saml.py new file mode 100644 index 000000000..a9d134d8a --- /dev/null +++ b/tests/units/test_saml.py @@ -0,0 +1,24 @@ +import pytest +from canarytokens.saml import extract_identity, prepare_request +from twisted.web.http import Request +from twisted.web.test.requesthelper import DummyChannel + +with open("data/sample_saml_data.txt") as f: + raw_text = f.read() + + +@pytest.mark.parametrize( + "args, expected_identity", + [ + ( + {b"SAMLResponse": [raw_text.encode()]}, + "tokens-testing@thinkst.com", + ) + ], +) +def test_extract_identity(args, expected_identity): + request = Request(channel=DummyChannel()) + request.args = args + prepared_request = prepare_request(request) + extracted_identity = extract_identity(prepared_request) + assert extracted_identity == expected_identity diff --git a/tests/utils.py b/tests/utils.py index 73323f750..4dc087cd8 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -613,6 +613,8 @@ def get_token_request(token_request_type: AnyTokenRequest) -> AnyTokenRequest: expected_referrer="testreferrer.com", windows_fake_fs_root=r"C:\Secrets", windows_fake_fs_file_structure="home_network", + app_type="aws", + webdav_fs_type="testing", )
+ + +

Identity

+

+ {{BasicDetails['identity']| e}} +

+