{
 "cells": [
  {
   "cell_type": "code",
   "id": "initial_id",
   "metadata": {
    "collapsed": true,
    "jupyter": {
     "outputs_hidden": true
    }
   },
   "source": [
    "from IPython.core.display import Markdown\n",
    "\n",
    "sets_to_analyse = [\"/tmp/scanning-results-full-leaks\"]\n",
    "data = {}\n",
    "for set_to_analyse in sets_to_analyse:\n",
    "    data[set_to_analyse] = {}"
   ],
   "outputs": [],
   "execution_count": null
  },
  {
   "cell_type": "code",
   "id": "1c5b207bccbbf27b",
   "metadata": {},
   "source": [
    "from ipywidgets import IntProgress\n",
    "import time\n",
    "import json\n",
    "from os import listdir\n",
    "from os.path import isfile, join\n",
    "from urllib.parse import urlparse\n",
    "\n",
    "for set_to_analyse in sets_to_analyse:\n",
    "    start_time = time.time()\n",
    "    files_to_analyse = [join(set_to_analyse, f) for f in listdir(set_to_analyse) if\n",
    "                        isfile(join(set_to_analyse, f)) and f.lower().endswith(\".json\")]\n",
    "    files_length = len(files_to_analyse)\n",
    "    data[set_to_analyse]['count_of_files'] = files_length\n",
    "    data[set_to_analyse]['leaks'] = []\n",
    "    data[set_to_analyse]['errors'] = []\n",
    "    progress = IntProgress(min=0, max=files_length)  # instantiate the bar\n",
    "    display(Markdown(f\"## Analysing %s\" % set_to_analyse))\n",
    "    display(progress)\n",
    "    count = 0\n",
    "    for f in files_to_analyse:\n",
    "        count += 1\n",
    "        if count % 10 == 0:\n",
    "            progress.value = count\n",
    "        with open(f) as json_file:\n",
    "            json_data = json.load(json_file)\n",
    "            if \"exception\" in json_data[\"analysis_result\"]:\n",
    "                data[set_to_analyse]['errors'].append({\"page\": json_data['analysis_config']['task'][0],\n",
    "                                                       \"error\": json_data[\"analysis_result\"][\"exception\"]})\n",
    "            else:\n",
    "                data[set_to_analyse]['leaks'].append({\"page\": json_data['analysis_config']['task'][0],\n",
    "                                                      \"file_name\": f,\n",
    "                                                      \"found_pleak\": json_data[\"analysis_result\"][\"found_lreq\"],\n",
    "                                                      \"found_full_leak\": json_data[\"analysis_result\"][\"full_leak\"],\n",
    "                                                      \"found_full_leak_type\": json_data[\"analysis_result\"][\n",
    "                                                          \"full_leak_type\"]})\n",
    "    print(f\"Finished analysis for %s in %s\" % (set_to_analyse, (time.time() - start_time)))"
   ],
   "outputs": [],
   "execution_count": null
  },
  {
   "metadata": {},
   "cell_type": "code",
   "source": [
    "import base64\n",
    "import zlib\n",
    "from urllib.parse import parse_qs, unquote\n",
    "from haralyzer import HarParser, HarPage\n",
    "\n",
    "############## CONFIG ###############\n",
    "show_leaks = False\n",
    "\n",
    "\n",
    "#####################################\n",
    "\n",
    "def get_token_from_query(query_parsed):\n",
    "    return {\n",
    "        \"type\": \"code\", \"value\": query_parsed['code']} if 'code' in query_parsed else {\n",
    "        \"type\": \"access_token\", \"value\": query_parsed['access_token']} if 'access_token' in query_parsed else {\n",
    "        \"type\": \"id_token\", \"value\": query_parsed['id_token']} if 'id_token' in query_parsed else {\n",
    "        \"type\": \"accesstoken\", \"value\": query_parsed['accesstoken']} if 'accesstoken' in query_parsed else {\n",
    "        \"type\": \"idtoken\", \"value\": query_parsed['idtoken']} if 'idtoken' in query_parsed else {\n",
    "        \"type\": \"credential\", \"value\": query_parsed['credential']} if \"credential\" in query_parsed else None\n",
    "\n",
    "\n",
    "true_positives_start_values = [\"ey\", \"EAA\", \"4/\", \"ya29.\", \"M.C535_\"]\n",
    "\n",
    "\n",
    "def check_for_token(querystring):\n",
    "    if not isinstance(querystring, dict):\n",
    "        try:\n",
    "            query_parsed = parse_qs(querystring)\n",
    "        except Exception as e:\n",
    "            print(\"ERROR: Could not parse %s\" % querystring, e)\n",
    "            return False\n",
    "    else:\n",
    "        query_parsed = querystring\n",
    "    token = get_token_from_query(query_parsed)\n",
    "    if token and isinstance(token['value'], list):\n",
    "        token[\"value\"] = token[\"value\"][0]\n",
    "    if token:\n",
    "        for t in true_positives_start_values:\n",
    "            if token[\"value\"].startswith(t):\n",
    "                return token\n",
    "    return None\n",
    "\n",
    "\n",
    "def find_token_for_leak(raw_leak):\n",
    "    type = raw_leak[\"found_full_leak_type\"]\n",
    "    if type == \"FB-S HEADER\":\n",
    "        print(\"Should not be searched for leaks (FB-S Header)\")\n",
    "        return None\n",
    "    elif type == \"FB-AR HEADER\":\n",
    "        value = json.loads(raw_leak[\"found_full_leak\"])\n",
    "        if not \"access_token\" in value:\n",
    "            raise Exception(\"Should not happen\")\n",
    "        return {\"type\": \"access_token\", \"value\": value['access_token']}\n",
    "    elif type == \"AUTH RESPONSE\":\n",
    "        pa = urlparse(raw_leak['found_full_leak'])\n",
    "        token = check_for_token(pa.query)\n",
    "        if not token:\n",
    "            token = check_for_token(pa.fragment)\n",
    "        return token\n",
    "    elif type == \"FORM POST\":\n",
    "        token = check_for_token(raw_leak[\"found_full_leak\"])\n",
    "        return token\n",
    "    elif type == \"GOOGLE POST MESSAGE RESPONSE\":\n",
    "        try:\n",
    "            token = check_for_token(raw_leak['found_full_leak']['data']['response'])\n",
    "            return token\n",
    "        except:\n",
    "            return None\n",
    "    elif type == \"FRAGMENT REDIRECT\":\n",
    "        token = check_for_token(urlparse(raw_leak['found_full_leak']).fragment)\n",
    "        return token\n",
    "    elif type == \"POST MESSAGE RESPONSE\":\n",
    "        token = check_for_token(raw_leak['found_full_leak']['data']['response'])\n",
    "        return token\n",
    "    else:\n",
    "        print(\"TODO: \" + raw_leak[\"found_full_leak_type\"])\n",
    "\n",
    "\n",
    "def is_false_positive(raw_leak):\n",
    "    return (raw_leak[\"found_full_leak_type\"] != \"FB-S HEADER\" and not find_token_for_leak(raw_leak)) or raw_leak[\n",
    "        'found_pleak'] is None\n",
    "\n",
    "\n",
    "final_leaks = {}\n",
    "found_leaks_token_types_global = {}\n",
    "for set_to_analyse in sets_to_analyse:\n",
    "    print(\"\\n\\nDATA FROM %s\" % set_to_analyse)\n",
    "    set_data = data[set_to_analyse]\n",
    "    found_pleaks = [l for l in set_data['leaks'] if l[\"found_pleak\"] is not None]\n",
    "    unique_pleaks = []\n",
    "    for f in found_pleaks:\n",
    "        c_id = parse_qs(urlparse(f['found_pleak']['lreq']).query)[\"client_id\"]\n",
    "        if c_id not in unique_pleaks:\n",
    "            unique_pleaks.append(c_id)\n",
    "    found_fleaks = [l for l in set_data['leaks'] if l[\"found_full_leak\"] is not None and not is_false_positive(l)]\n",
    "    found_false_positives = [l for l in set_data['leaks'] if l[\"found_full_leak\"] is not None and is_false_positive(l)]\n",
    "    print(f\"Found partial leaks: %s\" % len(found_pleaks))\n",
    "    print(\"Unique partial leaks: %s\" % len(unique_pleaks))\n",
    "    print(f\"Found full leaks: %s\" % len(found_fleaks))\n",
    "\n",
    "    final_leaks[set_to_analyse] = found_fleaks\n",
    "\n",
    "    sorted = {}\n",
    "    for l in found_fleaks:\n",
    "        if l[\"found_full_leak_type\"] not in sorted:\n",
    "            sorted[l[\"found_full_leak_type\"]] = []\n",
    "        sorted[l[\"found_full_leak_type\"]].append(l)\n",
    "    for s in sorted:\n",
    "        print(\"- %s: %s\" % (s, len(sorted[s])))\n",
    "\n",
    "    found_leaks_token_types = {}\n",
    "    for fleak in found_fleaks:\n",
    "        if fleak[\"found_full_leak_type\"] == \"FB-S HEADER\":\n",
    "            continue\n",
    "        token = find_token_for_leak(fleak)\n",
    "        if not token:\n",
    "            continue\n",
    "        if token['type'] not in found_leaks_token_types:\n",
    "            found_leaks_token_types[token['type']] = 0\n",
    "        if token['type'] not in found_leaks_token_types_global:\n",
    "            found_leaks_token_types_global[token['type']] = 0\n",
    "        found_leaks_token_types[token['type']] += 1\n",
    "        found_leaks_token_types_global[token['type']] += 1\n",
    "    print(\"Leaked Token Types\")\n",
    "    for tt in found_leaks_token_types:\n",
    "        print(\"  - %s: %s\" % (tt, found_leaks_token_types[tt]))\n",
    "\n",
    "    print(\"False positive full leaks: %s\" % len(found_false_positives))\n",
    "    print(f\"Errors %s\" % len(set_data['errors']))\n",
    "    print(\"\\n\")\n",
    "    if show_leaks:\n",
    "        for fl in found_fleaks:\n",
    "            print(\"- %s\" % fl['found_pleak']['lreq'])\n",
    "            print(\"\\n\\n\")\n",
    "    if False:\n",
    "        for l in [le for le in found_fleaks if le['found_full_leak_type'] != \"FB-S HEADER\"]:\n",
    "            token = find_token_for_leak(l)\n",
    "            if token:\n",
    "                value = token['value']\n",
    "                if len(value) > 10:\n",
    "                    print(value[:10] + \"...\")\n",
    "            else:\n",
    "                print(\"NONE!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\n",
    "print(\"\\nGLOBAL Types of Tokens\")\n",
    "for tt in found_leaks_token_types_global:\n",
    "    print(\"  - %s: %s\" % (tt, found_leaks_token_types_global[tt]))"
   ],
   "id": "e4cba083547492fd",
   "outputs": [],
   "execution_count": null
  },
  {
   "metadata": {},
   "cell_type": "code",
   "source": [
    "from pathlib import Path\n",
    "import csv\n",
    "\n",
    "unique_domains = []\n",
    "domains_with_duplicates = []\n",
    "# Export leaks (inclusive false positives):\n",
    "export_leaks = True\n",
    "if export_leaks:\n",
    "    output_file = \"/tmp/exported_fleaks{}.json\"\n",
    "    for set_to_analyse in sets_to_analyse:\n",
    "        file_name = output_file.replace(\"{}\", set_to_analyse.replace(\"./\", \"\").replace(\"/\", \"-\"))\n",
    "        Path(file_name).parent.mkdir(parents=True, exist_ok=True)\n",
    "        with open(file_name, \"w\", newline='') as f:\n",
    "            json.dump(data[set_to_analyse], f)\n",
    "        with open(file_name.replace(\".json\", \".csv\"), \"w\", newline='') as f:\n",
    "            writer = csv.writer(f)\n",
    "            for fleak in final_leaks[set_to_analyse]:\n",
    "                if fleak['found_full_leak']:\n",
    "                    writer.writerow([fleak['page'], fleak['found_pleak']['idp'], fleak['found_full_leak_type'],\n",
    "                                     fleak['found_full_leak']])\n",
    "                    domains_with_duplicates.append(fleak['page'])\n",
    "                    if fleak['page'] not in unique_domains:\n",
    "                        unique_domains.append(fleak['page'])\n",
    "        print(f\"Finished exporting %s\" % file_name)\n",
    "\n",
    "global_file_name = output_file.replace(\".json\", \".csv\").replace(\"{}\", \"-GLOBAL\")\n",
    "Path(global_file_name).parent.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "with open(global_file_name, \"w\", newline='') as f:\n",
    "    Path(file_name).parent.mkdir(parents=True, exist_ok=True)\n",
    "    writer = csv.writer(f)\n",
    "    for d in unique_domains:\n",
    "        writer.writerow([d])\n",
    "\n",
    "print(\"Finished exporting unique domains (%s).\" % len(unique_domains))\n",
    "\n",
    "with open(global_file_name.replace(\".csv\", \"_w_duplicates.csv\"), \"w\", newline='') as f:\n",
    "    Path(file_name).parent.mkdir(parents=True, exist_ok=True)\n",
    "    writer = csv.writer(f)\n",
    "    for d in domains_with_duplicates:\n",
    "        writer.writerow([d])\n",
    "print(\"Finished exporting domains (with duplicates) (%s).\" % len(domains_with_duplicates))\n",
    "\n"
   ],
   "id": "ab3f9dab3965f339",
   "outputs": [],
   "execution_count": null
  },
  {
   "metadata": {},
   "cell_type": "code",
   "source": [
    "# Fedcm analyse\n",
    "for set_to_analyse in sets_to_analyse:\n",
    "    opted_out_true, opted_out_false, opted_out_true_w_fleak, opted_out_false_w_fleak = 0, 0, 0, 0\n",
    "    set_data = data[set_to_analyse]['leaks']\n",
    "    for leak in set_data:\n",
    "        if leak['found_pleak']:\n",
    "            parsed_pleak = urlparse(leak['found_pleak']['lreq'])\n",
    "            if \"has_opted_out_fedcm\" in parsed_pleak.query:\n",
    "                qs = parse_qs(parsed_pleak.query)\n",
    "                if qs['has_opted_out_fedcm'][0].lower() == \"true\":\n",
    "                    opted_out_true += 1\n",
    "                    if leak['found_full_leak'] and not is_false_positive(leak):\n",
    "                        opted_out_true_w_fleak += 1\n",
    "                elif qs['has_opted_out_fedcm'][0].lower() == \"false\":\n",
    "                    opted_out_false += 1\n",
    "                    if leak['found_full_leak'] and not is_false_positive(leak):\n",
    "                        opted_out_false_w_fleak += 1\n",
    "                else:\n",
    "                    raise Exception()\n",
    "    if opted_out_true > 0 or opted_out_false > 0:\n",
    "        print(\"# %s\" % set_to_analyse)\n",
    "        print(\"Opted out fedcm true: %s\" % opted_out_true)\n",
    "        print(\"Opted out fedcm true with full leak: %s\" % opted_out_true_w_fleak)\n",
    "        print(\"Opted out fedcm false: %s\" % opted_out_false)\n",
    "        print(\"Opted out fedcm false with full leak: %s\" % opted_out_false_w_fleak)\n"
   ],
   "id": "463db89180a3722f",
   "outputs": [],
   "execution_count": null
  },
  {
   "metadata": {},
   "cell_type": "code",
   "source": [
    "from tldextract import tldextract\n",
    "from urllib import parse\n",
    "\n",
    "\n",
    "# Escalated Leaks\n",
    "def unquote_string(string_to_unquote):\n",
    "    try:\n",
    "        uq_string = parse.unquote(string_to_unquote)\n",
    "        return (uq_string if uq_string == string_to_unquote else unquote_string(uq_string))\n",
    "    except Exception as e:\n",
    "        print(e)\n",
    "        return string_to_unquote\n",
    "\n",
    "\n",
    "def get_origin_or_referer(request_to_check):\n",
    "    if urlparse(request_to_check['url']).netloc == \"mock.sso-monitor.me\":\n",
    "        js_data = json.loads(request_to_check['postData']['text'])\n",
    "        if \"origin\" in js_data:\n",
    "            return js_data[\"origin\"]\n",
    "        elif \"documentLocation\" in js_data and \"ancestorOrigins\" in js_data[\"documentLocation\"]:\n",
    "            last_origin = None\n",
    "            for numb in js_data[\"documentLocation\"][\"ancestorOrigins\"]:\n",
    "                last_origin = urlparse(js_data[\"documentLocation\"][\"ancestorOrigins\"][numb]).netloc\n",
    "            return last_origin\n",
    "        else:\n",
    "            return None\n",
    "    else:\n",
    "        for header in request_to_check['headers']:\n",
    "            if header['name'] == 'origin' or header['name'] == 'referer':\n",
    "                return header['value']\n",
    "    return None\n",
    "\n",
    "\n",
    "def is_cross_tld(request_to_check):\n",
    "    origin = get_origin_or_referer(request_to_check)\n",
    "    if not origin:\n",
    "        return False\n",
    "    if urlparse(request_to_check['url']).netloc == \"mock.sso-monitor.me\":\n",
    "        location = json.loads(request_to_check['postData']['text'])['documentLocation']['origin']\n",
    "    else:\n",
    "        location = req['url']\n",
    "    domain = tldextract.extract(origin).registered_domain\n",
    "    match = urlparse(location).netloc.endswith(domain)\n",
    "    return not match\n",
    "\n",
    "\n",
    "def ignore_netloc_because_idp(url, idp):\n",
    "    netloc = urlparse(url).netloc\n",
    "    if idp == None:\n",
    "        return netloc.endswith(\"facebook.com\") or netloc.endswith(\"google.com\") or netloc.endswith(\n",
    "            \"microsoft.com\") or netloc.endswith(\"newscorpaustralia.com\")\n",
    "    if idp == \"FACEBOOK\":\n",
    "        return netloc.endswith(\"facebook.com\")\n",
    "    if idp == \"GOOGLE\":\n",
    "        return netloc.endswith(\"google.com\") or netloc.endswith(\"googleapis.com\")\n",
    "    if idp == \"MICROSOFT\":\n",
    "        return netloc.endswith(\"microsoft.com\") or netloc.endswith(\"microsoftonline.com\") or netloc.endswith(\n",
    "            \"msftauth.net\")\n",
    "    if idp == \"GENERIC\":\n",
    "        return netloc.endswith(\"newscorpaustralia.com\")\n",
    "    else:\n",
    "        raise Exception(\"Should not happen\")\n",
    "\n",
    "\n",
    "def ignore_because_idp(req, idp):\n",
    "    return ignore_netloc_because_idp(req['url'], idp)\n",
    "\n",
    "\n",
    "def load_har_from_file(har_file, get_raw: bool = False):\n",
    "    loaded_data = json.load(har_file)['analysis_result']\n",
    "    if \"har\" not in loaded_data:\n",
    "        raise Exception(\"Should not happen\")\n",
    "    decoded_har = zlib.decompress(base64.b64decode(loaded_data['har']))\n",
    "    if get_raw:\n",
    "        return json.loads(decoded_har)\n",
    "    return HarParser(json.loads(decoded_har))\n",
    "\n",
    "\n",
    "escalated_leaks = {}\n",
    "skip, index = 0, -1\n",
    "for set_to_analyse in sets_to_analyse:\n",
    "    escalated_leaks[set_to_analyse] = []\n",
    "    set_leaks = final_leaks[set_to_analyse]\n",
    "    progress = IntProgress(min=0, max=len(set_leaks))  # instantiate the bar\n",
    "    display(Markdown(f\"## Analysing %s\" % set_to_analyse))\n",
    "    display(progress)\n",
    "    count = 0\n",
    "\n",
    "    for leak in set_leaks:\n",
    "        index += 1\n",
    "        if index <= skip:\n",
    "            continue\n",
    "        #print(\"Index: %s\" % index)\n",
    "        if leak[\"found_full_leak_type\"] == \"FB-S HEADER\":\n",
    "            count += 1\n",
    "            continue\n",
    "        with open(leak['file_name']) as file:\n",
    "            har = load_har_from_file(file)\n",
    "            leak_to_search = find_token_for_leak(leak)\n",
    "            leak_to_search_value = leak_to_search['value']\n",
    "            entries = har.har_data['entries']\n",
    "            for entry in entries:\n",
    "                found_escalated_leak = None\n",
    "                req = entry['request']\n",
    "                idp = leak['found_pleak']['idp'] if leak and leak['found_pleak'] else None\n",
    "                if not is_cross_tld(req) or ignore_because_idp(req, idp):\n",
    "                    continue\n",
    "                # Query String\n",
    "                for qs in req['queryString']:\n",
    "                    uq_string = unquote_string(qs['value'])\n",
    "                    if not found_escalated_leak and leak_to_search_value in uq_string:\n",
    "                        found_escalated_leak = {\"type\": \"QUERY_STRING\", \"request\": req}\n",
    "                # Message Data\n",
    "                if not found_escalated_leak and 'postData' in req and leak_to_search_value in str(req['postData']):\n",
    "                    if urlparse(req['url']).netloc == \"mock.sso-monitor.me\":\n",
    "                        post_data = json.loads(req['postData']['text'])\n",
    "                        if not ignore_netloc_because_idp(post_data['documentLocation']['origin'],\n",
    "                                                         leak['found_pleak']['idp']) and leak_to_search_value in \\\n",
    "                                post_data['data']:\n",
    "                            found_escalated_leak = {\"type\": \"POSTMESSAGE_LEAK\", \"request\": req}\n",
    "                    else:\n",
    "                        found_escalated_leak = {\"type\": \"POST_DATA\", \"request\": req}\n",
    "                # Headers\n",
    "                for h in req['headers']:\n",
    "                    uq_header = unquote_string(h['value'])\n",
    "                    if not found_escalated_leak and leak_to_search_value in uq_header:\n",
    "                        found_escalated_leak = {\"type\": \"HEADER_VALUE\", \"request\": req, \"header_name\": h['name']}\n",
    "                # Cookies\n",
    "                for c in req['cookies']:\n",
    "                    uq_cookie = unquote_string(c['value'])\n",
    "                    if not found_escalated_leak and leak_to_search_value in uq_cookie:\n",
    "                        found_escalated_leak = {\"type\": \"COOKIE_VALUE\", \"request\": req, \"cookie_name\": c['name']}\n",
    "\n",
    "                if found_escalated_leak:\n",
    "                    print(\"Found escalated leak!\")\n",
    "                    found_escalated_leak['fleak'] = leak\n",
    "                    found_escalated_leak['index'] = index\n",
    "                    escalated_leaks[set_to_analyse].append(found_escalated_leak)\n",
    "        count += 1\n",
    "        progress.value = count\n",
    "        #print(\"grep %s %s\" % (leak_to_search[\"value\"], leak['file_name']))"
   ],
   "id": "9b2f00ccda333eed",
   "outputs": [],
   "execution_count": null
  },
  {
   "metadata": {},
   "cell_type": "code",
   "source": [
    "export_file = \"/tmp/exported_eleaks.csv\"\n",
    "with open(export_file, \"w\", newline='') as file:\n",
    "    writer = csv.writer(file)\n",
    "    for set in escalated_leaks:\n",
    "        print(\"\\n\\nEscalated leaks for %s\" % set)\n",
    "        for e_leak in escalated_leaks[set]:\n",
    "            token = find_token_for_leak(e_leak['fleak'])\n",
    "            if e_leak['type'] == \"POSTMESSAGE_LEAK\":\n",
    "                e_leak_json_data = json.loads(e_leak['request']['postData']['text'])\n",
    "                origin = get_origin_or_referer(e_leak['request'])\n",
    "                if origin is None:\n",
    "                    print(\"ERROR\")\n",
    "                else:\n",
    "                    print(\"%s leaks from %s to %s (%s)\" % (token['type'],\n",
    "                                                           origin, urlparse(\n",
    "                        e_leak_json_data['documentLocation']['href']).netloc, e_leak['type']))\n",
    "            else:\n",
    "                print(\"%s leak from %s to %s (%s)\" % (token['type'],\n",
    "                                                      get_origin_or_referer(e_leak['request']),\n",
    "                                                      urlparse(e_leak['request']['url']).netloc, e_leak['type']))\n",
    "            writer.writerow([e_leak['fleak']['page']])\n",
    "\n"
   ],
   "id": "d958dff3e726b5ab",
   "outputs": [],
   "execution_count": null
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.10.12"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
