This commit is contained in:
Oli Passey
2025-01-27 16:08:18 +00:00
parent c393da970b
commit b8129538c3
7 changed files with 106 additions and 266 deletions

18
.env
View File

@@ -1,18 +0,0 @@
# Azure/SharePoint Credentials
AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_CLIENT_SECRET=SECRET
AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
EXCEL_FILE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
USER_EMAIL=x@xx.xxx
# SMTP Settings
SMTP_SERVER=email-smtp.eu-west-1.amazonaws.com
SMTP_PORT=587
SMTP_USERNAME=USER
SMTP_PASSWORD=PASSWORD
FROM_EMAIL=no-reply@domain.tld
FROM_NAME=Azure Secret Expiry Bot
TO_EMAIL=x@xx.xxx
# Teams Webhook Settings
TEAMS_WEBHOOK_URL=your-teams-webhook-url

167
.gitignore vendored
View File

@@ -1,166 +1 @@
# Byte-compiled / optimized / DLL files /aio/__pycache__
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
debug_app_registrations.json
app_registrations.html
.env
.env

View File

@@ -1,35 +1,49 @@
# AzAppRegistrationExpiry # Azure Function App
A simple python app to warn of upcoming App Registration Secret / Password Expiry on Azure Entra ID. This project is an Azure Function that authenticates to the Microsoft Graph API and fetches app registrations. The function is triggered by an HTTP request.
## Installation ## Project Structure
Requires Python 3.12
Install requirements from requirements.txt
```bash
pip install -r requirements.txt
``` ```
azure-function-app
├── aio
│ ├── __init__.py # Contains the main logic for the Azure Function
│ └── function.json # Configuration for the Azure Function
├── local.settings.json # Local configuration settings
├── requirements.txt # Required Python packages
└── README.md # Project documentation
```
## Setup Instructions
1. **Clone the repository**:
```
git clone <repository-url>
cd azure-function-app
```
2. **Install dependencies**:
Make sure you have Python installed, then run:
```
pip install -r requirements.txt
```
3. **Configure environment variables**:
Create a `.env` file or set the following environment variables in `local.settings.json`:
- `AZURE_CLIENT_ID`: Your Azure AD application client ID
- `AZURE_CLIENT_SECRET`: Your Azure AD application client secret
- `AZURE_TENANT_ID`: Your Azure AD tenant ID
4. **Run the function locally**:
Use the Azure Functions Core Tools to run the function:
```
func start
```
## Usage ## Usage
Amend the credentials in .env to match your environment. Once the function is running, you can trigger it by sending an HTTP request to the endpoint provided in the console output. The function will authenticate to the Microsoft Graph API and return the app registrations.
You will need to create an App Registration with API Permissions:
- Application.ReadWrite.All
- Files.ReadWrite.All
- Sites.ReadWrite.All
- User.Read
- User.Read.All
Create an Excel Sheet within Business OneDrive and add the ID to the .env file (sourcedoc=xxx in the URL) ## License
Add SMTP Sending details to .env (AWS Simple E-Mail Service was used in development)
This project is licensed under the MIT License.
```python
python main.py
```
## Contributing
Pull requests are welcome. For major changes, please open an issue first
to discuss what you would like to change.

View File

@@ -5,16 +5,21 @@ import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr from email.utils import formataddr
from datetime import datetime, timezone from datetime import datetime, timezone
from dotenv import load_dotenv
import requests import requests
import msal import msal
import azure.functions as func
# Load environment variables
load_dotenv()
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def main(myTimer: func.TimerRequest) -> None:
logging.info("Processing a request to fetch app registrations.")
app_registrations = get_app_registrations()
if app_registrations:
sorted_app_registrations = sort_app_registrations(app_registrations)
send_notifications(sorted_app_registrations)
def get_app_registrations(): def get_app_registrations():
logging.info("Authenticating to Microsoft Graph API") logging.info("Authenticating to Microsoft Graph API")
@@ -73,8 +78,8 @@ def get_app_registrations():
def sort_app_registrations(app_registrations): def sort_app_registrations(app_registrations):
current_date = datetime.now(timezone.utc) current_date = datetime.now(timezone.utc)
for app in app_registrations: for app in app_registrations:
if app["passwordCredentials"]: for credential in app["passwordCredentials"]:
expiry_date_str = app["passwordCredentials"][0]["endDateTime"] expiry_date_str = credential["endDateTime"]
try: try:
if '.' in expiry_date_str: if '.' in expiry_date_str:
expiry_date_str = expiry_date_str.split('.')[0] + '.' + expiry_date_str.split('.')[1][:6] + 'Z' expiry_date_str = expiry_date_str.split('.')[0] + '.' + expiry_date_str.split('.')[1][:6] + 'Z'
@@ -84,13 +89,10 @@ def sort_app_registrations(app_registrations):
except ValueError: except ValueError:
expiry_date = datetime.strptime(expiry_date_str, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc) expiry_date = datetime.strptime(expiry_date_str, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
days_to_expiry = (expiry_date - current_date).days days_to_expiry = (expiry_date - current_date).days
app["days_to_expiry"] = days_to_expiry credential["days_to_expiry"] = days_to_expiry
app["expiry_date"] = expiry_date.isoformat() credential["expiry_date"] = expiry_date.isoformat()
else:
app["days_to_expiry"] = None
app["expiry_date"] = None
sorted_apps = sorted(app_registrations, key=lambda x: (x["days_to_expiry"] is None, x["days_to_expiry"]), reverse=False) sorted_apps = sorted(app_registrations, key=lambda x: (min([cred["days_to_expiry"] for cred in x["passwordCredentials"]]) if x["passwordCredentials"] else float('inf')), reverse=False)
return sorted_apps return sorted_apps
def generate_html(app_registrations): def generate_html(app_registrations):
@@ -178,40 +180,32 @@ def generate_html(app_registrations):
""" """
for app in app_registrations: for app in app_registrations:
password_credentials = app.get('passwordCredentials', []) owners = app.get('owners', [])
if not password_credentials: owner_upns = [owner.get('userPrincipalName') for owner in owners if owner.get('userPrincipalName')]
continue owner_list = ', '.join(owner_upns) if owner_upns else 'No owners'
expiry_date = password_credentials[0].get('endDateTime') for credential in app.get('passwordCredentials', []):
if expiry_date: expiry_date = credential.get('expiry_date')
try: days_to_expiry = credential.get('days_to_expiry')
if '.' in expiry_date:
expiry_date = expiry_date.split('.')[0] + '.' + expiry_date.split('.')[1][:6] + 'Z' if days_to_expiry is not None:
if expiry_date.endswith('ZZ'): if days_to_expiry > 30:
expiry_date = expiry_date[:-1] color_class = "green"
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%dT%H:%M:%S.%fZ').replace(tzinfo=timezone.utc) elif 7 < days_to_expiry <= 30:
except ValueError: color_class = "yellow"
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc) elif 1 <= days_to_expiry <= 7:
days_to_expiry = (expiry_date - datetime.now(timezone.utc)).days color_class = "orange"
else:
if days_to_expiry > 30: color_class = "red"
color_class = "green" days_to_expiry = "EXPIRED"
elif 7 < days_to_expiry <= 30:
color_class = "yellow"
elif 1 <= days_to_expiry <= 7:
color_class = "orange"
else: else:
color_class = "red" color_class = "red"
days_to_expiry = "EXPIRED" days_to_expiry = "EXPIRED"
owners = app.get('owners', [])
owner_upns = [owner.get('userPrincipalName') for owner in owners if owner.get('userPrincipalName')]
owner_list = ', '.join(owner_upns) if owner_upns else 'No owners'
html += f""" html += f"""
<tr class="{color_class}"> <tr class="{color_class}">
<td>{app['displayName']}</td> <td>{app['displayName']}</td>
<td>{expiry_date.strftime('%Y-%m-%d')}</td> <td>{expiry_date.split('T')[0]}</td>
<td>{days_to_expiry}</td> <td>{days_to_expiry}</td>
<td>{owner_list}</td> <td>{owner_list}</td>
</tr> </tr>
@@ -238,11 +232,14 @@ def send_notifications(app_registrations):
# Generate HTML content # Generate HTML content
html_content = generate_html(app_registrations) html_content = generate_html(app_registrations)
# Export JSON and HTML files # Collect unique owner email addresses
with open('debug_app_registrations.json', 'w') as f: unique_owner_emails = set()
json.dump(app_registrations, f, indent=2) for app in app_registrations:
with open('app_registrations.html', 'w') as f: owners = app.get('owners', [])
f.write(html_content) for owner in owners:
email = owner.get('userPrincipalName')
if email:
unique_owner_emails.add(email)
# Create email message # Create email message
subject = "App Registration Expiry Notification" subject = "App Registration Expiry Notification"
@@ -250,18 +247,14 @@ def send_notifications(app_registrations):
msg['Subject'] = subject msg['Subject'] = subject
msg['From'] = formataddr((from_name, from_email)) msg['From'] = formataddr((from_name, from_email))
msg['To'] = to_email msg['To'] = to_email
msg['Cc'] = ', '.join(unique_owner_emails)
try: try:
logging.info(f"Sending email to {to_email}") logging.info(f"Sending email to {to_email} with CC to {', '.join(unique_owner_emails)}")
with smtplib.SMTP(smtp_server, smtp_port) as server: with smtplib.SMTP(smtp_server, smtp_port) as server:
server.starttls() server.starttls()
server.login(smtp_username, smtp_password) server.login(smtp_username, smtp_password)
server.sendmail(from_email, [to_email], msg.as_string()) server.sendmail(from_email, [to_email] + list(unique_owner_emails), msg.as_string())
logging.info("Successfully sent email") logging.info("Successfully sent email")
except Exception as e: except Exception as e:
logging.error(f"Failed to send email: {e}") logging.error(f"Failed to send email: {e}")
if __name__ == "__main__":
app_registrations = get_app_registrations()
sorted_app_registrations = sort_app_registrations(app_registrations)
send_notifications(sorted_app_registrations)

10
aio/function.json Normal file
View File

@@ -0,0 +1,10 @@
{
"bindings": [
{
"type": "timerTrigger",
"direction": "in",
"name": "myTimer",
"schedule": "0 0 9 * * 1-5"
}
]
}

7
host.json Normal file
View File

@@ -0,0 +1,7 @@
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}

View File

@@ -1,5 +1,4 @@
azure-identity azure-functions
azure-mgmt-resource==23.2.0 msal
msal==1.24.0 requests
python-dotenv==1.0.0 python-dotenv
requests==2.31.0