AzFunc
This commit is contained in:
18
.env
18
.env
@@ -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
167
.gitignore
vendored
@@ -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__
|
||||
|
||||
66
README.md
66
README.md
@@ -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.
|
||||
@@ -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
10
aio/function.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"bindings": [
|
||||
{
|
||||
"type": "timerTrigger",
|
||||
"direction": "in",
|
||||
"name": "myTimer",
|
||||
"schedule": "0 0 9 * * 1-5"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
host.json
Normal file
7
host.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user