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
__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
/aio/__pycache__

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
Amend the credentials in .env to match your environment.
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
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.
Create an Excel Sheet within Business OneDrive and add the ID to the .env file (sourcedoc=xxx in the URL)
Add SMTP Sending details to .env (AWS Simple E-Mail Service was used in development)
## 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.
This project is licensed under the MIT License.

View File

@@ -5,16 +5,21 @@ import smtplib
from email.mime.text import MIMEText
from email.utils import formataddr
from datetime import datetime, timezone
from dotenv import load_dotenv
import requests
import msal
# Load environment variables
load_dotenv()
import azure.functions as func
# Configure logging
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():
logging.info("Authenticating to Microsoft Graph API")
@@ -73,8 +78,8 @@ def get_app_registrations():
def sort_app_registrations(app_registrations):
current_date = datetime.now(timezone.utc)
for app in app_registrations:
if app["passwordCredentials"]:
expiry_date_str = app["passwordCredentials"][0]["endDateTime"]
for credential in app["passwordCredentials"]:
expiry_date_str = credential["endDateTime"]
try:
if '.' in expiry_date_str:
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:
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
app["days_to_expiry"] = days_to_expiry
app["expiry_date"] = expiry_date.isoformat()
else:
app["days_to_expiry"] = None
app["expiry_date"] = None
credential["days_to_expiry"] = days_to_expiry
credential["expiry_date"] = expiry_date.isoformat()
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
def generate_html(app_registrations):
@@ -178,40 +180,32 @@ def generate_html(app_registrations):
"""
for app in app_registrations:
password_credentials = app.get('passwordCredentials', [])
if not password_credentials:
continue
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'
expiry_date = password_credentials[0].get('endDateTime')
if expiry_date:
try:
if '.' in expiry_date:
expiry_date = expiry_date.split('.')[0] + '.' + expiry_date.split('.')[1][:6] + 'Z'
if expiry_date.endswith('ZZ'):
expiry_date = expiry_date[:-1]
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%dT%H:%M:%S.%fZ').replace(tzinfo=timezone.utc)
except ValueError:
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
days_to_expiry = (expiry_date - datetime.now(timezone.utc)).days
if days_to_expiry > 30:
color_class = "green"
elif 7 < days_to_expiry <= 30:
color_class = "yellow"
elif 1 <= days_to_expiry <= 7:
color_class = "orange"
for credential in app.get('passwordCredentials', []):
expiry_date = credential.get('expiry_date')
days_to_expiry = credential.get('days_to_expiry')
if days_to_expiry is not None:
if days_to_expiry > 30:
color_class = "green"
elif 7 < days_to_expiry <= 30:
color_class = "yellow"
elif 1 <= days_to_expiry <= 7:
color_class = "orange"
else:
color_class = "red"
days_to_expiry = "EXPIRED"
else:
color_class = "red"
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"""
<tr class="{color_class}">
<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>{owner_list}</td>
</tr>
@@ -238,11 +232,14 @@ def send_notifications(app_registrations):
# Generate HTML content
html_content = generate_html(app_registrations)
# Export JSON and HTML files
with open('debug_app_registrations.json', 'w') as f:
json.dump(app_registrations, f, indent=2)
with open('app_registrations.html', 'w') as f:
f.write(html_content)
# Collect unique owner email addresses
unique_owner_emails = set()
for app in app_registrations:
owners = app.get('owners', [])
for owner in owners:
email = owner.get('userPrincipalName')
if email:
unique_owner_emails.add(email)
# Create email message
subject = "App Registration Expiry Notification"
@@ -250,18 +247,14 @@ def send_notifications(app_registrations):
msg['Subject'] = subject
msg['From'] = formataddr((from_name, from_email))
msg['To'] = to_email
msg['Cc'] = ', '.join(unique_owner_emails)
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:
server.starttls()
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")
except Exception as 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)
logging.error(f"Failed to send email: {e}")

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-mgmt-resource==23.2.0
msal==1.24.0
python-dotenv==1.0.0
requests==2.31.0
azure-functions
msal
requests
python-dotenv