audit-labs/tutorials

Learn how to perform data analysis, scripting, automation, and more!

clone: git clone https://gitbay.org/audit-labs/tutorials.git

8bd4c1ee76acb835258933634aab19ca68230cfb

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2025-12-24T03:51:44Z

add terminations notebook
 .gitignore                                         |   2 +-
 notebooks/Untitled.ipynb                           | 137 ++++++++
 .../basic_data_analysis.ipynb                      |   0
 .../racf_access_analysis.ipynb                     |   0
 .../sample_racf_data.txt                           |   0
 .../terminations/Termination_Audit_Report.xlsx     | Bin 0 -> 6811 bytes
 notebooks/terminations/Terminations.ipynb          | 373 +++++++++++++++++++++
 notebooks/terminations/app_users.csv               |   7 +
 notebooks/terminations/hr_terminations.csv         |   6 +
 9 files changed, 524 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index c66c39d..87620ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1 @@
-notebooks/.ipynb_checkpoints
+.ipynb_checkpoints/
diff --git a/notebooks/Untitled.ipynb b/notebooks/Untitled.ipynb
new file mode 100644
index 0000000..de0838a
--- /dev/null
+++ b/notebooks/Untitled.ipynb
@@ -0,0 +1,137 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "84b491a8-4ce7-44f3-aed9-feba2ddd1b3b",
+   "metadata": {},
+   "outputs": [
+    {
+     "ename": "FileNotFoundError",
+     "evalue": "[Errno 2] No such file or directory: 'hr_terminations.csv'",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[31m---------------------------------------------------------------------------\u001b[39m",
+      "\u001b[31mFileNotFoundError\u001b[39m                         Traceback (most recent call last)",
+      "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 4\u001b[39m\n\u001b[32m      1\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mpandas\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mas\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mpd\u001b[39;00m\n\u001b[32m      3\u001b[39m \u001b[38;5;66;03m# Load the datasets\u001b[39;00m\n\u001b[32m----> \u001b[39m\u001b[32m4\u001b[39m df_hr = \u001b[43mpd\u001b[49m\u001b[43m.\u001b[49m\u001b[43mread_csv\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mhr_terminations.csv\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[32m      5\u001b[39m df_app = pd.read_csv(\u001b[33m'\u001b[39m\u001b[33mapp_abc_users.csv\u001b[39m\u001b[33m'\u001b[39m)\n\u001b[32m      7\u001b[39m \u001b[38;5;66;03m# Convert date columns to actual datetime objects immediately\u001b[39;00m\n",
+      "\u001b[36mFile \u001b[39m\u001b[32m/opt/homebrew/Cellar/jupyterlab/4.5.1/libexec/lib/python3.14/site-packages/pandas/io/parsers/readers.py:1026\u001b[39m, in \u001b[36mread_csv\u001b[39m\u001b[34m(filepath_or_buffer, sep, delimiter, header, names, index_col, usecols, dtype, engine, converters, true_values, false_values, skipinitialspace, skiprows, skipfooter, nrows, na_values, keep_default_na, na_filter, verbose, skip_blank_lines, parse_dates, infer_datetime_format, keep_date_col, date_parser, date_format, dayfirst, cache_dates, iterator, chunksize, compression, thousands, decimal, lineterminator, quotechar, quoting, doublequote, escapechar, comment, encoding, encoding_errors, dialect, on_bad_lines, delim_whitespace, low_memory, memory_map, float_precision, storage_options, dtype_backend)\u001b[39m\n\u001b[32m   1013\u001b[39m kwds_defaults = _refine_defaults_read(\n\u001b[32m   1014\u001b[39m     dialect,\n\u001b[32m   1015\u001b[39m     delimiter,\n\u001b[32m   (...)\u001b[39m\u001b[32m   1022\u001b[39m     dtype_backend=dtype_backend,\n\u001b[32m   1023\u001b[39m )\n\u001b[32m   1024\u001b[39m kwds.update(kwds_defaults)\n\u001b[32m-> \u001b[39m\u001b[32m1026\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43m_read\u001b[49m\u001b[43m(\u001b[49m\u001b[43mfilepath_or_buffer\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mkwds\u001b[49m\u001b[43m)\u001b[49m\n",
+      "\u001b[36mFile \u001b[39m\u001b[32m/opt/homebrew/Cellar/jupyterlab/4.5.1/libexec/lib/python3.14/site-packages/pandas/io/parsers/readers.py:620\u001b[39m, in \u001b[36m_read\u001b[39m\u001b[34m(filepath_or_buffer, kwds)\u001b[39m\n\u001b[32m    617\u001b[39m _validate_names(kwds.get(\u001b[33m\"\u001b[39m\u001b[33mnames\u001b[39m\u001b[33m\"\u001b[39m, \u001b[38;5;28;01mNone\u001b[39;00m))\n\u001b[32m    619\u001b[39m \u001b[38;5;66;03m# Create the parser.\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m620\u001b[39m parser = \u001b[43mTextFileReader\u001b[49m\u001b[43m(\u001b[49m\u001b[43mfilepath_or_buffer\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mkwds\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m    622\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m chunksize \u001b[38;5;129;01mor\u001b[39;00m iterator:\n\u001b[32m    623\u001b[39m     \u001b[38;5;28;01mreturn\u001b[39;00m parser\n",
+      "\u001b[36mFile \u001b[39m\u001b[32m/opt/homebrew/Cellar/jupyterlab/4.5.1/libexec/lib/python3.14/site-packages/pandas/io/parsers/readers.py:1620\u001b[39m, in \u001b[36mTextFileReader.__init__\u001b[39m\u001b[34m(self, f, engine, **kwds)\u001b[39m\n\u001b[32m   1617\u001b[39m     \u001b[38;5;28mself\u001b[39m.options[\u001b[33m\"\u001b[39m\u001b[33mhas_index_names\u001b[39m\u001b[33m\"\u001b[39m] = kwds[\u001b[33m\"\u001b[39m\u001b[33mhas_index_names\u001b[39m\u001b[33m\"\u001b[39m]\n\u001b[32m   1619\u001b[39m \u001b[38;5;28mself\u001b[39m.handles: IOHandles | \u001b[38;5;28;01mNone\u001b[39;00m = \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[32m-> \u001b[39m\u001b[32m1620\u001b[39m \u001b[38;5;28mself\u001b[39m._engine = \u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43m_make_engine\u001b[49m\u001b[43m(\u001b[49m\u001b[43mf\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43mengine\u001b[49m\u001b[43m)\u001b[49m\n",
+      "\u001b[36mFile \u001b[39m\u001b[32m/opt/homebrew/Cellar/jupyterlab/4.5.1/libexec/lib/python3.14/site-packages/pandas/io/parsers/readers.py:1880\u001b[39m, in \u001b[36mTextFileReader._make_engine\u001b[39m\u001b[34m(self, f, engine)\u001b[39m\n\u001b[32m   1878\u001b[39m     \u001b[38;5;28;01mif\u001b[39;00m \u001b[33m\"\u001b[39m\u001b[33mb\u001b[39m\u001b[33m\"\u001b[39m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;129;01min\u001b[39;00m mode:\n\u001b[32m   1879\u001b[39m         mode += \u001b[33m\"\u001b[39m\u001b[33mb\u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m-> \u001b[39m\u001b[32m1880\u001b[39m \u001b[38;5;28mself\u001b[39m.handles = \u001b[43mget_handle\u001b[49m\u001b[43m(\u001b[49m\n\u001b[32m   1881\u001b[39m \u001b[43m    \u001b[49m\u001b[43mf\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m   1882\u001b[39m \u001b[43m    \u001b[49m\u001b[43mmode\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m   1883\u001b[39m \u001b[43m    \u001b[49m\u001b[43mencoding\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43moptions\u001b[49m\u001b[43m.\u001b[49m\u001b[43mget\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mencoding\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mNone\u001b[39;49;00m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m   1884\u001b[39m \u001b[43m    \u001b[49m\u001b[43mcompression\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43moptions\u001b[49m\u001b[43m.\u001b[49m\u001b[43mget\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mcompression\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mNone\u001b[39;49;00m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m   1885\u001b[39m \u001b[43m    \u001b[49m\u001b[43mmemory_map\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43moptions\u001b[49m\u001b[43m.\u001b[49m\u001b[43mget\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mmemory_map\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mFalse\u001b[39;49;00m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m   1886\u001b[39m \u001b[43m    \u001b[49m\u001b[43mis_text\u001b[49m\u001b[43m=\u001b[49m\u001b[43mis_text\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m   1887\u001b[39m \u001b[43m    \u001b[49m\u001b[43merrors\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43moptions\u001b[49m\u001b[43m.\u001b[49m\u001b[43mget\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mencoding_errors\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mstrict\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m   1888\u001b[39m \u001b[43m    \u001b[49m\u001b[43mstorage_options\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43moptions\u001b[49m\u001b[43m.\u001b[49m\u001b[43mget\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mstorage_options\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mNone\u001b[39;49;00m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m   1889\u001b[39m \u001b[43m\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m   1890\u001b[39m \u001b[38;5;28;01massert\u001b[39;00m \u001b[38;5;28mself\u001b[39m.handles \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[32m   1891\u001b[39m f = \u001b[38;5;28mself\u001b[39m.handles.handle\n",
+      "\u001b[36mFile \u001b[39m\u001b[32m/opt/homebrew/Cellar/jupyterlab/4.5.1/libexec/lib/python3.14/site-packages/pandas/io/common.py:873\u001b[39m, in \u001b[36mget_handle\u001b[39m\u001b[34m(path_or_buf, mode, encoding, compression, memory_map, is_text, errors, storage_options)\u001b[39m\n\u001b[32m    868\u001b[39m \u001b[38;5;28;01melif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(handle, \u001b[38;5;28mstr\u001b[39m):\n\u001b[32m    869\u001b[39m     \u001b[38;5;66;03m# Check whether the filename is to be opened in binary mode.\u001b[39;00m\n\u001b[32m    870\u001b[39m     \u001b[38;5;66;03m# Binary mode does not support 'encoding' and 'newline'.\u001b[39;00m\n\u001b[32m    871\u001b[39m     \u001b[38;5;28;01mif\u001b[39;00m ioargs.encoding \u001b[38;5;129;01mand\u001b[39;00m \u001b[33m\"\u001b[39m\u001b[33mb\u001b[39m\u001b[33m\"\u001b[39m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;129;01min\u001b[39;00m ioargs.mode:\n\u001b[32m    872\u001b[39m         \u001b[38;5;66;03m# Encoding\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m873\u001b[39m         handle = \u001b[38;5;28;43mopen\u001b[39;49m\u001b[43m(\u001b[49m\n\u001b[32m    874\u001b[39m \u001b[43m            \u001b[49m\u001b[43mhandle\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m    875\u001b[39m \u001b[43m            \u001b[49m\u001b[43mioargs\u001b[49m\u001b[43m.\u001b[49m\u001b[43mmode\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m    876\u001b[39m \u001b[43m            \u001b[49m\u001b[43mencoding\u001b[49m\u001b[43m=\u001b[49m\u001b[43mioargs\u001b[49m\u001b[43m.\u001b[49m\u001b[43mencoding\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m    877\u001b[39m \u001b[43m            \u001b[49m\u001b[43merrors\u001b[49m\u001b[43m=\u001b[49m\u001b[43merrors\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m    878\u001b[39m \u001b[43m            \u001b[49m\u001b[43mnewline\u001b[49m\u001b[43m=\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[32m    879\u001b[39m \u001b[43m        \u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m    880\u001b[39m     \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[32m    881\u001b[39m         \u001b[38;5;66;03m# Binary mode\u001b[39;00m\n\u001b[32m    882\u001b[39m         handle = \u001b[38;5;28mopen\u001b[39m(handle, ioargs.mode)\n",
+      "\u001b[31mFileNotFoundError\u001b[39m: [Errno 2] No such file or directory: 'hr_terminations.csv'"
+     ]
+    }
+   ],
+   "source": [
+    "import pandas as pd\n",
+    "\n",
+    "# Load the datasets\n",
+    "df_hr = pd.read_csv('hr_terminations.csv')\n",
+    "df_app = pd.read_csv('app_abc_users.csv')\n",
+    "\n",
+    "# Convert date columns to actual datetime objects immediately\n",
+    "df_hr['Term_Date'] = pd.to_datetime(df_hr['Term_Date'])\n",
+    "df_app['Last_Login'] = pd.to_datetime(df_app['Last_Login'])\n",
+    "\n",
+    "print(f\"HR Records: {len(df_hr)}\")\n",
+    "print(f\"App Records: {len(df_app)}\")\n",
+    "df_hr.head()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f67f851a-ee33-405a-abf4-9393f1837047",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Strip whitespace from IDs and Names to prevent 'false negatives'\n",
+    "df_hr['Employee_ID'] = df_hr['Employee_ID'].str.strip()\n",
+    "df_app['User_ID'] = df_app['User_ID'].str.strip()\n",
+    "\n",
+    "# Standardizing names for easier visual review later\n",
+    "df_hr['Name'] = df_hr['Name'].str.strip().str.title()\n",
+    "df_app['Full_Name'] = df_app['Full_Name'].str.strip().str.title()\n",
+    "\n",
+    "print(\"Data cleaning complete.\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6a32518c-fe0a-4fb9-89fe-7796d5267957",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# We join on the ID. \n",
+    "# We use 'left' because we only care about people on the termination list.\n",
+    "audit_merge = pd.merge(\n",
+    "    df_hr, \n",
+    "    df_app, \n",
+    "    left_on='Employee_ID', \n",
+    "    right_on='User_ID', \n",
+    "    how='left'\n",
+    ")\n",
+    "\n",
+    "# Display the merged table\n",
+    "audit_merge"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b46cc115-8fee-476c-9f8c-6980d49a000f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# 1. Identify Terminated but still 'Active' in Application\n",
+    "active_leavers = audit_merge[audit_merge['Account_Status'] == 'Active'].copy()\n",
+    "\n",
+    "# 2. Identify Logins occurring AFTER termination date\n",
+    "# This is a critical security finding indicating potential account misuse\n",
+    "post_term_logins = audit_merge[audit_merge['Last_Login'] > audit_merge['Term_Date']].copy()\n",
+    "\n",
+    "print(f\"Finding 1: {len(active_leavers)} users still marked as 'Active'\")\n",
+    "print(f\"Finding 2: {len(post_term_logins)} users logged in after termination\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9644d4b3-9682-4ebe-acfc-1474218c58b0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Create a summary report\n",
+    "with pd.ExcelWriter('Termination_Audit_Report.xlsx') as writer:\n",
+    "    active_leavers.to_excel(writer, sheet_name='Active_Leavers', index=False)\n",
+    "    post_term_logins.to_excel(writer, sheet_name='Post_Term_Logins', index=False)\n",
+    "    audit_merge.to_excel(writer, sheet_name='Full_Traceability_Matrix', index=False)\n",
+    "\n",
+    "print(\"Audit Report Exported: Termination_Audit_Report.xlsx\")"
+   ]
+  }
+ ],
+ "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.14.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/basic_data_analysis.ipynb b/notebooks/basic_data_analysis/basic_data_analysis.ipynb
similarity index 100%
rename from notebooks/basic_data_analysis.ipynb
rename to notebooks/basic_data_analysis/basic_data_analysis.ipynb
diff --git a/notebooks/racf_access_analysis.ipynb b/notebooks/racf_access_analysis/racf_access_analysis.ipynb
similarity index 100%
rename from notebooks/racf_access_analysis.ipynb
rename to notebooks/racf_access_analysis/racf_access_analysis.ipynb
diff --git a/notebooks/sample_racf_data.txt b/notebooks/racf_access_analysis/sample_racf_data.txt
similarity index 100%
rename from notebooks/sample_racf_data.txt
rename to notebooks/racf_access_analysis/sample_racf_data.txt
diff --git a/notebooks/terminations/Termination_Audit_Report.xlsx b/notebooks/terminations/Termination_Audit_Report.xlsx
new file mode 100644
index 0000000..8ad794b
Binary files /dev/null and b/notebooks/terminations/Termination_Audit_Report.xlsx differ
diff --git a/notebooks/terminations/Terminations.ipynb b/notebooks/terminations/Terminations.ipynb
new file mode 100644
index 0000000..5c4abb3
--- /dev/null
+++ b/notebooks/terminations/Terminations.ipynb
@@ -0,0 +1,373 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "84b491a8-4ce7-44f3-aed9-feba2ddd1b3b",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "HR Records: 5\n",
+      "App Records: 6\n"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>Employee_ID</th>\n",
+       "      <th>Name</th>\n",
+       "      <th>Term_Date</th>\n",
+       "      <th>Department</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>E001</td>\n",
+       "      <td>Alice Smith</td>\n",
+       "      <td>2023-11-15</td>\n",
+       "      <td>Sales</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>E005</td>\n",
+       "      <td>Bob Johnson</td>\n",
+       "      <td>2023-12-01</td>\n",
+       "      <td>IT</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>E010</td>\n",
+       "      <td>Charlie Brown</td>\n",
+       "      <td>2024-01-10</td>\n",
+       "      <td>Finance</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>E012</td>\n",
+       "      <td>David Miller</td>\n",
+       "      <td>2023-10-20</td>\n",
+       "      <td>Marketing</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>E015</td>\n",
+       "      <td>Eve Wilson</td>\n",
+       "      <td>2023-12-25</td>\n",
+       "      <td>Engineering</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "  Employee_ID           Name  Term_Date   Department\n",
+       "0        E001    Alice Smith 2023-11-15        Sales\n",
+       "1        E005    Bob Johnson 2023-12-01           IT\n",
+       "2        E010  Charlie Brown 2024-01-10      Finance\n",
+       "3        E012   David Miller 2023-10-20    Marketing\n",
+       "4        E015     Eve Wilson 2023-12-25  Engineering"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "import pandas as pd\n",
+    "\n",
+    "# Load the datasets\n",
+    "df_hr = pd.read_csv('hr_terminations.csv')\n",
+    "df_app = pd.read_csv('app_users.csv')\n",
+    "\n",
+    "# Convert date columns to actual datetime objects immediately\n",
+    "df_hr['Term_Date'] = pd.to_datetime(df_hr['Term_Date'])\n",
+    "df_app['Last_Login'] = pd.to_datetime(df_app['Last_Login'])\n",
+    "\n",
+    "print(f\"HR Records: {len(df_hr)}\")\n",
+    "print(f\"App Records: {len(df_app)}\")\n",
+    "df_hr.head()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "1ebfee0c-aece-471a-8d21-49232d672cce",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Data cleaning complete.\n"
+     ]
+    }
+   ],
+   "source": [
+    "# Strip whitespace from IDs and Names to prevent 'false negatives'\n",
+    "df_hr['Employee_ID'] = df_hr['Employee_ID'].str.strip()\n",
+    "df_app['User_ID'] = df_app['User_ID'].str.strip()\n",
+    "\n",
+    "# Standardizing names for easier visual review later\n",
+    "df_hr['Name'] = df_hr['Name'].str.strip().str.title()\n",
+    "df_app['Full_Name'] = df_app['Full_Name'].str.strip().str.title()\n",
+    "\n",
+    "print(\"Data cleaning complete.\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "d5a547ec-e787-4b1e-ae77-07ab5b77b980",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>Employee_ID</th>\n",
+       "      <th>Name</th>\n",
+       "      <th>Term_Date</th>\n",
+       "      <th>Department</th>\n",
+       "      <th>User_ID</th>\n",
+       "      <th>Full_Name</th>\n",
+       "      <th>Account_Status</th>\n",
+       "      <th>Last_Login</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>E001</td>\n",
+       "      <td>Alice Smith</td>\n",
+       "      <td>2023-11-15</td>\n",
+       "      <td>Sales</td>\n",
+       "      <td>E001</td>\n",
+       "      <td>Alice Smith</td>\n",
+       "      <td>Active</td>\n",
+       "      <td>2024-01-05</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>E005</td>\n",
+       "      <td>Bob Johnson</td>\n",
+       "      <td>2023-12-01</td>\n",
+       "      <td>IT</td>\n",
+       "      <td>E005</td>\n",
+       "      <td>Bob Johnson</td>\n",
+       "      <td>Active</td>\n",
+       "      <td>2023-11-28</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>E010</td>\n",
+       "      <td>Charlie Brown</td>\n",
+       "      <td>2024-01-10</td>\n",
+       "      <td>Finance</td>\n",
+       "      <td>E010</td>\n",
+       "      <td>Charlie Brown</td>\n",
+       "      <td>Disabled</td>\n",
+       "      <td>2024-01-08</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>E012</td>\n",
+       "      <td>David Miller</td>\n",
+       "      <td>2023-10-20</td>\n",
+       "      <td>Marketing</td>\n",
+       "      <td>NaN</td>\n",
+       "      <td>NaN</td>\n",
+       "      <td>NaN</td>\n",
+       "      <td>NaT</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>E015</td>\n",
+       "      <td>Eve Wilson</td>\n",
+       "      <td>2023-12-25</td>\n",
+       "      <td>Engineering</td>\n",
+       "      <td>E015</td>\n",
+       "      <td>Eve Wilson</td>\n",
+       "      <td>Active</td>\n",
+       "      <td>2024-02-01</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "  Employee_ID           Name  Term_Date   Department User_ID      Full_Name  \\\n",
+       "0        E001    Alice Smith 2023-11-15        Sales    E001    Alice Smith   \n",
+       "1        E005    Bob Johnson 2023-12-01           IT    E005    Bob Johnson   \n",
+       "2        E010  Charlie Brown 2024-01-10      Finance    E010  Charlie Brown   \n",
+       "3        E012   David Miller 2023-10-20    Marketing     NaN            NaN   \n",
+       "4        E015     Eve Wilson 2023-12-25  Engineering    E015     Eve Wilson   \n",
+       "\n",
+       "  Account_Status Last_Login  \n",
+       "0         Active 2024-01-05  \n",
+       "1         Active 2023-11-28  \n",
+       "2       Disabled 2024-01-08  \n",
+       "3            NaN        NaT  \n",
+       "4         Active 2024-02-01  "
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# We join on the ID. \n",
+    "# We use 'left' because we only care about people on the termination list.\n",
+    "audit_merge = pd.merge(\n",
+    "    df_hr, \n",
+    "    df_app, \n",
+    "    left_on='Employee_ID', \n",
+    "    right_on='User_ID', \n",
+    "    how='left'\n",
+    ")\n",
+    "\n",
+    "# Display the merged table\n",
+    "audit_merge"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "5db62c12-6f2d-48cc-b008-39e135348f00",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Finding 1: 3 users still marked as 'Active'\n",
+      "Finding 2: 2 users logged in after termination\n"
+     ]
+    }
+   ],
+   "source": [
+    "# 1. Identify Terminated but still 'Active' in Application\n",
+    "active_leavers = audit_merge[audit_merge['Account_Status'] == 'Active'].copy()\n",
+    "\n",
+    "# 2. Identify Logins occurring AFTER termination date\n",
+    "# This is a critical security finding indicating potential account misuse\n",
+    "post_term_logins = audit_merge[audit_merge['Last_Login'] > audit_merge['Term_Date']].copy()\n",
+    "\n",
+    "print(f\"Finding 1: {len(active_leavers)} users still marked as 'Active'\")\n",
+    "print(f\"Finding 2: {len(post_term_logins)} users logged in after termination\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "67c23470-4c0d-422d-bd1a-b0ad19487aca",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Collecting openpyxl\n",
+      "  Downloading openpyxl-3.1.5-py2.py3-none-any.whl.metadata (2.5 kB)\n",
+      "Collecting et-xmlfile (from openpyxl)\n",
+      "  Downloading et_xmlfile-2.0.0-py3-none-any.whl.metadata (2.7 kB)\n",
+      "Downloading openpyxl-3.1.5-py2.py3-none-any.whl (250 kB)\n",
+      "Downloading et_xmlfile-2.0.0-py3-none-any.whl (18 kB)\n",
+      "Installing collected packages: et-xmlfile, openpyxl\n",
+      "\u001b[2K   \u001b[38;2;114;156;31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2/2\u001b[0m [openpyxl]━━\u001b[0m \u001b[32m1/2\u001b[0m [openpyxl]\n",
+      "\u001b[1A\u001b[2KSuccessfully installed et-xmlfile-2.0.0 openpyxl-3.1.5\n",
+      "Note: you may need to restart the kernel to use updated packages.\n"
+     ]
+    }
+   ],
+   "source": [
+    "%pip install openpyxl"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "e2fd2073-04e0-41f9-b76f-1677967e96b9",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Audit Report Exported: Termination_Audit_Report.xlsx\n"
+     ]
+    }
+   ],
+   "source": [
+    "# Create a summary report\n",
+    "with pd.ExcelWriter('Termination_Audit_Report.xlsx') as writer:\n",
+    "    active_leavers.to_excel(writer, sheet_name='Active_Leavers', index=False)\n",
+    "    post_term_logins.to_excel(writer, sheet_name='Post_Term_Logins', index=False)\n",
+    "    audit_merge.to_excel(writer, sheet_name='Full_Traceability_Matrix', index=False)\n",
+    "\n",
+    "print(\"Audit Report Exported: Termination_Audit_Report.xlsx\")"
+   ]
+  }
+ ],
+ "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.14.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/terminations/app_users.csv b/notebooks/terminations/app_users.csv
new file mode 100644
index 0000000..cb9bd51
--- /dev/null
+++ b/notebooks/terminations/app_users.csv
@@ -0,0 +1,7 @@
+User_ID,Full_Name,Account_Status,Last_Login
+E001,Alice Smith,Active,2024-01-05
+E002,Frank Wright,Active,2024-02-12
+E005,Bob Johnson ,Active,2023-11-28
+E009,Grace Hopper,Active,2024-02-15
+E010,Charlie Brown,Disabled,2024-01-08
+E015,Eve Wilson,Active,2024-02-01
diff --git a/notebooks/terminations/hr_terminations.csv b/notebooks/terminations/hr_terminations.csv
new file mode 100644
index 0000000..e227397
--- /dev/null
+++ b/notebooks/terminations/hr_terminations.csv
@@ -0,0 +1,6 @@
+Employee_ID,Name,Term_Date,Department
+E001,Alice Smith,2023-11-15,Sales
+E005,Bob Johnson,2023-12-01,IT
+E010,Charlie Brown,2024-01-10,Finance
+E012,David Miller,2023-10-20,Marketing
+E015,Eve Wilson,2023-12-25,Engineering