Skip to content

Commit 8193c6b

Browse files
committed
Refactor test config, add __str__ to audit types, clean up examples
- Replace semicolon env var with named env vars in .env/tests/settings.py - Add __str__/__repr__ to AppIdentity, AuditActivityInitiator, TargetResource - Add password_credentials property to ServicePrincipal - Add ErrorFolderExists to DuplicatedObjectException detection - Rewrite sp_report.py as service principal credential audit - Rewrite group_membership_changes.py using __str__ methods - Rewrite attack_simulation.py with _fmt helper - Add license examples: modify_service_plans, remove, find_licensed, usage_report - Use fluent require_role() in rotate_secret.py
1 parent 5a899be commit 8193c6b

37 files changed

Lines changed: 552 additions & 307 deletions

.gitignore

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
# OpenClaw workspace files (agent local state)
2-
.openclaw/
3-
AGENTS.md
4-
HEARTBEAT.md
5-
IDENTITY.md
6-
SOUL.md
7-
TOOLS.md
8-
USER.md
9-
MEMORY.md
10-
memory/
1+
.env
112

123
# Python artifacts
134
__pycache__/

examples/entraid/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ tenant-level policies, and audit logs.
104104
| Register a new application | [`applications/create.py`](./applications/create.py) | `Application.ReadWrite.All` | [create app](https://learn.microsoft.com/en-us/graph/api/application-post-applications) |
105105
| List all applications | [`applications/list.py`](./applications/list.py) | `Application.Read.All` | [list apps](https://learn.microsoft.com/en-us/graph/api/application-list) |
106106
| Get an application by client ID | [`applications/get_by_app_id.py`](./applications/get_by_app_id.py) | `Application.ReadWrite.All` | [get application](https://learn.microsoft.com/en-us/graph/api/application-get) |
107-
| Add a certificate to an app | [`applications/add_cert.py`](./applications/add_cert.py) | `Application.ReadWrite.All` | [add certificate](https://learn.microsoft.com/en-us/graph/api/application-post-certificates) |
107+
| Add a certificate to an app | [`applications/add_cert.py`](applications/rotate_cert.py) | `Application.ReadWrite.All` | [add certificate](https://learn.microsoft.com/en-us/graph/api/application-post-certificates) |
108108
| Add a client secret (password) | [`applications/app_password.py`](applications/rotate_password.py) | `Application.ReadWrite.All` | [add password](https://learn.microsoft.com/en-us/graph/api/application-add-password) |
109109
| Check application permissions | [`applications/has_application_perms.py`](./applications/has_application_perms.py) | `AppRoleAssignment.ReadWrite.All` | [app permissions](https://learn.microsoft.com/en-us/graph/api/application-list-approleassignments) |
110110
| Check delegated permissions | [`applications/has_delegated_perms.py`](./applications/has_delegated_perms.py) | `AppRoleAssignment.ReadWrite.All` | [delegated perms](https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-delegatedpermissions) |

examples/entraid/applications/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Manage app registrations, certificate credentials, and API permissions.
66

77
| What | File | Notes |
88
|------|------|-------|
9-
| **Add certificate** | [`add_cert.py`](./add_cert.py) | Upload a certificate to an app registration |
9+
| **Add certificate** | [`add_cert.py`](rotate_cert.py) | Upload a certificate to an app registration |
1010
| **Add password** | [`app_password.py`](rotate_password.py) | Create a client secret for an app |
1111
| **Get by app ID** | [`get_by_app_id.py`](./get_by_app_id.py) | Find an app registration by its client ID |
1212

examples/entraid/applications/certificate_expiry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def main():
1818
for cred in app.key_credentials:
1919
if cred.endDateTime and cred.endDateTime <= cutoff:
2020
days = (cred.endDateTime - datetime.now(timezone.utc)).days
21-
hint = cred.display_name or cred.key_id or ""
21+
hint = cred.displayName or cred.keyId or ""
2222
print(f" {app.display_name} hint={hint} expires={cred.endDateTime.date()} ({days}d)")
2323

2424

examples/entraid/applications/add_cert.py renamed to examples/entraid/applications/rotate_cert.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
cert_path = "../../selfsigncert.pem"
1616

1717
client = GraphClient(tenant=test_tenant).with_username_and_password(test_client_id, test_username, test_password)
18+
1819
target_app = client.applications.get_by_app_id(test_client_id)
1920
with open(cert_path, "rb") as f:
2021
cert_data = f.read()

examples/entraid/applications/rotate_password.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,19 @@
55
Requires delegated permission ``Application.ReadWrite.All``.
66
"""
77

8-
import sys
9-
10-
from office365.directory.permissions.guard import has_role
118
from office365.graph_client import GraphClient
12-
from tests import (
13-
test_admin_principal_name,
14-
test_client_id,
15-
test_tenant,
9+
from tests.settings import (
10+
admin_username,
11+
client_id,
12+
tenant,
1613
)
1714

18-
client = GraphClient(tenant=test_tenant).with_token_interactive(test_client_id, test_admin_principal_name)
19-
if not has_role(client, "Global Administrator", "Privileged Role Administrator"):
20-
print("Need Global Administrator or Privileged Role Administrator role to grant permissions.")
21-
sys.exit(1)
15+
client = (
16+
GraphClient(tenant=tenant)
17+
.with_token_interactive(client_id, admin_username)
18+
.require_role("Global Administrator", "Privileged Role Administrator")
19+
)
2220

23-
target_app = client.applications.get_by_app_id(test_client_id)
21+
target_app = client.applications.get_by_app_id(client_id)
2422
result = target_app.add_password("Password friendly name").execute_query()
2523
print(result.value.secretText)

examples/entraid/applications/rotate_secret.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,18 @@
99
https://learn.microsoft.com/en-us/graph/api/application-addpassword
1010
"""
1111

12-
import sys
13-
14-
from office365.directory.permissions.guard import has_role
1512
from office365.graph_client import GraphClient
16-
from tests import test_admin_principal_name, test_client_id, test_tenant
13+
from tests.settings import admin_username, client_id, tenant
1714

1815

1916
def main():
20-
client = GraphClient(tenant=test_tenant).with_token_interactive(test_client_id, test_admin_principal_name)
21-
if not has_role(client, "Global Administrator", "Privileged Role Administrator"):
22-
print("Need Global Administrator or Privileged Role Administrator role to grant permissions.")
23-
sys.exit(1)
17+
client = (
18+
GraphClient(tenant=tenant)
19+
.with_token_interactive(client_id, admin_username)
20+
.require_role("Global Administrator", "Privileged Role Administrator")
21+
)
2422

25-
app = client.applications.get_by_app_id(test_client_id)
23+
app = client.applications.get_by_app_id(client_id)
2624
result = app.add_password("Rotated secret").execute_query()
2725
print(f"CLIENT_SECRET={result.value.secretText}")
2826

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,49 @@
11
"""
2-
List service principals in the tenant.
2+
Audit service principal credentials — list all SPs with their password
3+
and certificate expiry dates for security compliance.
34
4-
For delegated permissions see has_delegated_perms.py / list_delegated_perms.py.
5-
For expiring secrets see password_expiry.py / certificate_expiry.py.
5+
Requires delegated permission ``Application.Read.All``.
66
7-
Requires delegated permission Application.Read.All.
7+
https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list
88
"""
99

10+
from datetime import datetime, timezone
11+
1012
from office365.graph_client import GraphClient
1113
from tests import test_client_id, test_client_secret, test_tenant
1214

13-
client = GraphClient(tenant=test_tenant).with_client_secret(test_client_id, test_client_secret)
15+
NOW = datetime.now(timezone.utc)
16+
_WARN_DAYS = 30
17+
18+
19+
def _status(cred):
20+
if not cred.endDateTime or cred.endDateTime == datetime.min:
21+
return " no_expiry"
22+
remaining = (cred.endDateTime - NOW).days
23+
if remaining < 0:
24+
return f" expired {abs(remaining)}d ago"
25+
if remaining < _WARN_DAYS:
26+
return f" expires in {remaining}d <<<"
27+
return f" expires in {remaining}d"
28+
29+
30+
def main():
31+
client = GraphClient(tenant=test_tenant).with_client_secret(test_client_id, test_client_secret)
32+
33+
fields = ["displayName", "appId", "passwordCredentials", "keyCredentials"]
34+
sps = client.service_principals.select(fields).get().execute_query()
35+
36+
print(f"Service principal credential audit ({len(sps)} SPs)\n")
37+
for sp in sorted(sps, key=lambda x: x.display_name or ""):
38+
if not sp.password_credentials and not sp.key_credentials:
39+
continue
40+
41+
print(f"\n {sp.display_name} (app_id={sp.app_id})")
42+
for p in sp.password_credentials:
43+
print(f" [P] {p.displayName or '(unnamed)'}{_status(p)}")
44+
for k in sp.key_credentials:
45+
print(f" [C] {k.displayName or '(unnamed)'}{_status(k)}")
46+
1447

15-
sps = client.service_principals.top(20).get().execute_query()
16-
print(f"Service principals ({len(sps)}):")
17-
for sp in sps:
18-
print(f" {sp.display_name} app_id={sp.app_id or '?'}")
48+
if __name__ == "__main__":
49+
main()

examples/entraid/audit/group_membership_changes.py

Lines changed: 38 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,8 @@
22
Audit group membership changes — track who was added or removed from
33
Microsoft 365 and security groups.
44
5-
Uses directory audit logs to detect Add member to group and Remove
6-
member from group events. Useful for compliance monitoring and detecting
7-
unauthorized group membership modifications.
8-
9-
Inspired by Report-GroupMembershipChanges.PS1 from the community.
5+
Uses directory audit logs to detect group membership modifications.
6+
Useful for compliance monitoring and detecting unauthorized changes.
107
118
Required delegated permissions:
129
AuditLog.Read.All Read directory audit logs
@@ -21,92 +18,55 @@
2118
from office365.graph_client import GraphClient
2219
from tests import test_client_id, test_client_secret, test_tenant
2320

21+
TARGET_ACTIVITIES = {
22+
"Add member to group",
23+
"Remove member from group",
24+
"Add member to role",
25+
"Remove member from role",
26+
}
2427

25-
def query_group_membership_changes(days_back: int = 30) -> list[dict]:
26-
"""Query directory audit logs for group membership changes.
27-
28-
Args:
29-
days_back: How many days to look back.
3028

31-
Returns:
32-
List of change event dicts.
33-
"""
29+
def main():
3430
client = GraphClient(tenant=test_tenant).with_client_secret(test_client_id, test_client_secret)
3531

3632
end = datetime.now(timezone.utc)
37-
start = end - timedelta(days=days_back)
33+
start = end - timedelta(days=30)
3834

39-
changes = []
40-
41-
try:
42-
audit_logs = (
43-
client.audit_logs.directory_audits.filter(
44-
f"activityDateTime ge {start.strftime('%Y-%m-%dT%H:%M:%SZ')} and "
45-
f"activityDateTime le {end.strftime('%Y-%m-%dT%H:%M:%SZ')}"
46-
)
47-
.get()
48-
.execute_query()
35+
audit_logs = (
36+
client.audit_logs.directory_audits.filter(
37+
f"activityDateTime ge {start.strftime('%Y-%m-%dT%H:%M:%SZ')} and "
38+
f"activityDateTime le {end.strftime('%Y-%m-%dT%H:%M:%SZ')}"
4939
)
40+
.get()
41+
.execute_query()
42+
)
5043

51-
target_activities = {
52-
"Add member to group",
53-
"Remove member from group",
54-
"Add member to role",
55-
"Remove member from role",
56-
}
57-
58-
for log in audit_logs:
59-
activity = getattr(log, "activity_display_name", "")
60-
if activity not in target_activities:
61-
continue
62-
63-
# Parse target resource (the group)
64-
targets = getattr(log, "target_resources", [])
65-
group_name = "Unknown"
66-
member_name = "Unknown"
67-
member_upn = ""
68-
69-
for t in targets or []:
70-
ttype = getattr(t, "type", "").lower()
71-
if ttype == "group":
72-
group_name = getattr(t, "display_name", getattr(t, "id", "Unknown"))
73-
elif ttype == "user":
74-
member_name = getattr(t, "display_name", "Unknown")
75-
member_upn = getattr(t, "user_principal_name", "")
76-
77-
changes.append(
78-
{
79-
"timestamp": getattr(log, "activity_date_time", None),
80-
"activity": activity,
81-
"group": group_name,
82-
"member": member_name,
83-
"member_upn": member_upn,
84-
"initiated_by": getattr(log, "initiated_by", {})
85-
.get("user", {})
86-
.get("user_principal_name", "Unknown"),
87-
}
88-
)
89-
except Exception as e:
90-
print(f" Warning: could not query audit logs: {e}")
91-
92-
changes.sort(key=lambda x: x["timestamp"] or datetime.min, reverse=True)
93-
return changes
94-
95-
96-
def main():
97-
print("Group membership change audit (last 30 days)\n")
98-
changes = query_group_membership_changes(days_back=30)
44+
changes = []
45+
for log in audit_logs:
46+
if log.activity_display_name not in TARGET_ACTIVITIES:
47+
continue
48+
49+
changes.append(
50+
{
51+
"timestamp": log.activity_datetime,
52+
"activity": log.activity_display_name,
53+
"group": str(next((t for t in log.target_resources if (t.type or "").lower() == "group"), "")),
54+
"member": str(next((t for t in log.target_resources if (t.type or "").lower() == "user"), "")),
55+
"initiated_by": str(log.initiated_by),
56+
}
57+
)
9958

10059
if not changes:
101-
print("No group membership changes found in directory audit logs.")
60+
print("No group membership changes found in the last 30 days.")
10261
return
10362

104-
print(f"Found {len(changes)} changes:\n")
63+
changes.sort(key=lambda x: x["timestamp"] or datetime.min, reverse=True)
64+
65+
print(f"Group membership changes (last 30 days): {len(changes)} found\n")
10566
for c in changes:
106-
ts = c["timestamp"].strftime("%Y-%m-%d %H:%M") if c["timestamp"] else "Unknown"
107-
action = "➕" if "Add" in c["activity"] else "➖"
108-
member = c["member_upn"] or c["member"]
109-
print(f" {action} {c['activity']:28s} {member:40s}{c['group']:30s} by {c['initiated_by']} ({ts})")
67+
ts = c["timestamp"].strftime("%Y-%m-%d %H:%M") if c["timestamp"] else "?"
68+
action = "[+]" if "Add" in c["activity"] else "[-]"
69+
print(f" {action} {c['activity']:28s} {c['member']:40s} -> {c['group']:30s} by {c['initiated_by']} ({ts})")
11070

11171

11272
if __name__ == "__main__":

examples/licenses/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Licensing
22

33
Examples for working with Microsoft 365 licenses via Graph API —
4-
SKU inventory, license assignment, and usage reports.
4+
SKU inventory, license assignment, service plan management, and usage reports.
55

66
---
77

@@ -21,6 +21,10 @@ SKU inventory, license assignment, and usage reports.
2121
|---|---|
2222
| License inventory and unlicensed user report | [`report.py`](./report.py) |
2323
| License assignment and SKU switch | [`assign.py`](./assign.py) |
24+
| **Disable a service plan within a license** | [`modify_service_plans.py`](./modify_service_plans.py) |
25+
| **Remove a license from a user** | [`remove.py`](./remove.py) |
26+
| **Find all users with a specific SKU** | [`find_licensed.py`](./find_licensed.py) |
27+
| **Full license usage report** | [`usage_report.py`](./usage_report.py) |
2428

2529
---
2630

0 commit comments

Comments
 (0)