-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathbackend.py
More file actions
4397 lines (3771 loc) · 242 KB
/
Copy pathbackend.py
File metadata and controls
4397 lines (3771 loc) · 242 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
M365 Assessment Tool — Python Backend v2
M365 Assessment Toolkit
Run with: python backend.py
Requires: pip install flask flask-cors
Requires: Node.js + npm install docx (run once in tool folder)
Supports two authentication methods:
- Interactive: Browser popup per workload (no setup required)
- App Registration: Tenant ID + Client ID + Client Secret (unattended)
Exchange Online, Teams, and SharePoint always use interactive login
regardless of auth method — these workloads do not support app-only
client credential auth via PowerShell in the same way as Graph.
"""
from flask import Flask, request, jsonify, send_file
from flask_cors import CORS
import subprocess, json, os, datetime, csv, io
import urllib.request, urllib.parse, urllib.error
app = Flask(__name__)
CORS(app)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SCRIPTS_DIR = os.path.join(BASE_DIR, "scripts")
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
REPORTS_DIR = os.path.join(BASE_DIR, "reports")
for d in [SCRIPTS_DIR, OUTPUT_DIR, REPORTS_DIR]:
os.makedirs(d, exist_ok=True)
# ─────────────────────────────────────────────────────────────
# FINDINGS LIBRARY
# ─────────────────────────────────────────────────────────────
def build_findings_library():
return [
# Identity
{"id":"ID-001","title":"Low MFA Coverage","module":"identity","metric":"mfa_percentage","severity":"critical",
"threshold": lambda v: isinstance(v,(int,float)) and v < 95,
"description":"Fewer than 95% of licensed users have MFA registered. This significantly increases account compromise risk.",
"recommendation":"Enable MFA for all users via Conditional Access. Consider enabling Security Defaults if no CA policies exist.",
"secure_score_impact": 16},
{"id":"ID-002","title":"Excessive Global Administrators","module":"identity","metric":"global_admin_count","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v > 3,
"description":"More than 3 Global Administrators detected. Global Admin is the highest-privilege role and should be minimised.",
"recommendation":"Reduce Global Admins to 2–3 break-glass accounts. Use least-privilege roles for day-to-day admin tasks.",
"secure_score_impact": 5},
{"id":"ID-003","title":"No Privileged Identity Management","module":"identity","metric":"pim_enabled","severity":"high",
"threshold": lambda v: v is False,
"description":"PIM is not in use. Permanent role assignments expand the attack surface unnecessarily.",
"recommendation":"Enable Entra PIM and convert permanent admin role assignments to eligible (just-in-time) assignments.",
"secure_score_impact": 10},
{"id":"ID-004","title":"High Guest User Count","module":"identity","metric":"guest_user_count","severity":"medium",
"threshold": lambda v: isinstance(v,(int,float)) and v > 50,
"description":"A large number of guest accounts exist in the tenant. Unreviewed guests represent a data exposure risk.",
"recommendation":"Implement an access review policy for guest accounts. Remove guests who no longer require access.",
"secure_score_impact": 3},
{"id":"ID-005","title":"Unused Licences","module":"identity","metric":"unassigned_licence_percentage","severity":"medium",
"threshold": lambda v: isinstance(v,(int,float)) and v > 20,
"description":"More than 20% of purchased licences are unassigned, representing unnecessary cost.",
"recommendation":"Audit unassigned licences and remove from the subscription where no longer required.",
"secure_score_impact": 0},
# Security & CA
{"id":"SEC-001","title":"Low Secure Score","module":"security","metric":"secure_score_percentage","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v < 50,
"description":"Microsoft Secure Score is below 50%, indicating significant security controls are missing.",
"recommendation":"Review the Secure Score dashboard in Defender portal. Prioritise high-impact, low-effort recommendations first.",
"secure_score_impact": 0},
{"id":"SEC-002","title":"Security Defaults Disabled — No CA Policies","module":"security","metric":"security_defaults_enabled","severity":"critical",
"threshold": lambda v, m: v is False and m.get("ca_enabled_policy_count", 0) == 0,
"description":"Security Defaults are disabled and no compensating Conditional Access policies may be in place.",
"recommendation":"Either re-enable Security Defaults or implement an equivalent baseline CA policy set covering MFA and legacy auth blocking.",
"secure_score_impact": 12},
{"id":"CA-001","title":"No Conditional Access Policies Enabled","module":"security","metric":"ca_enabled_policy_count","severity":"critical",
"threshold": lambda v: isinstance(v,(int,float)) and v == 0,
"description":"No enabled Conditional Access policies found. Access to M365 is not context-aware.",
"recommendation":"Deploy baseline CA policies: MFA for all users, MFA for admins, block legacy auth, require compliant devices.",
"secure_score_impact": 15},
{"id":"CA-002","title":"Legacy Authentication Not Blocked","module":"security","metric":"legacy_auth_blocked","severity":"critical",
"threshold": lambda v: v is False,
"description":"Legacy authentication protocols are not blocked. These bypass MFA and are heavily exploited.",
"recommendation":"Create a CA policy to block all legacy authentication. Audit dependencies before enforcing.",
"secure_score_impact": 10},
{"id":"CA-003","title":"No CA Policy Enforcing MFA for All Users","module":"security","metric":"mfa_all_users_ca_policy","severity":"critical",
"threshold": lambda v: v is False,
"description":"There is no Conditional Access policy that enforces multi-factor authentication for all users. Even with CA policies in place, if none of them target all users with an MFA requirement, entire user populations can authenticate with just a password. Credential stuffing, phishing and password spray attacks succeed instantly against accounts with no MFA enforcement.",
"recommendation":"Create a CA policy targeting all users (excluding break-glass accounts), all cloud apps, and requiring MFA as the grant control. This is the single most impactful CA control you can deploy. Test with a pilot group first, then broaden to all users.",
"secure_score_impact": 10},
# Exchange
{"id":"EXO-001","title":"Auto-Forwarding Allowed to External","module":"exchange","metric":"external_forwarding_blocked","severity":"high",
"threshold": lambda v: v is False,
"description":"Automatic email forwarding to external recipients is not blocked. This is a common data exfiltration vector.",
"recommendation":"Set AutoForwardingMode to 'Automatic' block in the outbound spam filter policy, or create a transport rule to block external auto-forwarding.",
"secure_score_impact": 5},
{"id":"EXO-002","title":"Mailbox Auditing Disabled","module":"exchange","metric":"mailbox_audit_enabled_percentage","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v < 90,
"description":"Mailbox auditing is not enabled for all mailboxes. Audit logs are essential for forensic investigation.",
"recommendation":"Enable mailbox auditing organisation-wide using Set-OrganizationConfig -AuditDisabled $false.",
"secure_score_impact": 5},
{"id":"EXO-003","title":"Anti-Phishing Intelligence Disabled","module":"exchange","metric":"antiphish_intelligence_enabled","severity":"medium",
"threshold": lambda v: v is False,
"description":"Mailbox intelligence in anti-phishing policies is not enabled, reducing protection against targeted attacks.",
"recommendation":"Enable mailbox intelligence and impersonation protection in the anti-phishing policy.",
"secure_score_impact": 5},
# Teams
{"id":"TEAMS-001","title":"Unrestricted External Access","module":"teams","metric":"teams_external_access_restricted","severity":"medium",
"threshold": lambda v: v is False,
"description":"Teams external access (federation) is not restricted. Users can communicate with any external Teams tenant.",
"recommendation":"Restrict Teams external access to approved domains only, or disable it if not required.",
"secure_score_impact": 3},
{"id":"TEAMS-002","title":"Teams Consumer Access Enabled","module":"teams","metric":"teams_consumer_access_blocked","severity":"medium",
"threshold": lambda v: v is False,
"description":"Users can communicate with Teams personal/consumer accounts, increasing data leakage risk.",
"recommendation":"Disable Teams consumer access unless there is a specific business requirement.",
"secure_score_impact": 3},
{"id":"TEAMS-003","title":"Anonymous Users Can Join Meetings","module":"teams","metric":"teams_anon_meeting_join_enabled","severity":"medium",
"threshold": lambda v: v is True,
"description":"The global Teams meeting policy allows anonymous users to join meetings without authentication. Anyone with a meeting link can join as a guest with no identity verification. This enables uninvited participants to join internal calls, access shared content, and potentially record sensitive discussions.",
"recommendation":"In the Teams Admin Centre, go to Meetings > Meeting policies > Global > Participants & guests. Set 'Anonymous users can join a meeting' to Off. Create an exception policy for specific users or groups with a legitimate need.",
"secure_score_impact": 3},
{"id":"TEAMS-004","title":"Third-Party Teams Apps Unrestricted","module":"teams","metric":"teams_third_party_apps_allowed","severity":"medium",
"threshold": lambda v: v is True,
"description":"The global Teams app permission policy allows all third-party apps from the Teams store without restriction. Users can install apps that have permissions to read messages, files, and meeting content. Malicious or compromised third-party apps are a growing attack surface in Teams environments.",
"recommendation":"In Teams Admin Centre, go to Teams apps > Permission policies > Global. Change third-party apps from Allow all to either Block all or allow specific approved apps only. Review and approve a whitelist of business-critical third-party apps.",
"secure_score_impact": 2},
# SharePoint
{"id":"SPO-001","title":"SharePoint Sharing Set to Anyone","module":"sharepoint","metric":"spo_sharing_level","severity":"critical",
"threshold": lambda v: v == "ExternalUserAndGuestSharing",
"description":"SharePoint/OneDrive external sharing is set to Anyone, allowing unauthenticated link sharing.",
"recommendation":"Restrict sharing to 'New and existing guests' (ExternalUserSharingOnly) at minimum. Review per site collection.",
"secure_score_impact": 8},
{"id":"SPO-002","title":"Legacy Authentication Enabled in SharePoint","module":"sharepoint","metric":"spo_legacy_auth","severity":"high",
"threshold": lambda v: v is True,
"description":"Legacy authentication protocols are enabled in SharePoint, bypassing modern auth controls.",
"recommendation":"Disable LegacyAuthProtocolsEnabled in SharePoint tenant settings.",
"secure_score_impact": 5},
{"id":"SPO-003","title":"OneDrive External Sharing Unrestricted","module":"sharepoint","metric":"onedrive_sharing_level","severity":"high",
"threshold": lambda v: v == "ExternalUserAndGuestSharing",
"description":"OneDrive for Business external sharing is set to Anyone, allowing users to create unauthenticated sharing links. SharePoint and OneDrive have separate sharing settings — a tenant can restrict SharePoint while leaving OneDrive open. Files shared via anonymous links are accessible to anyone with the URL, with no authentication or audit trail.",
"recommendation":"In SharePoint Admin Centre, go to Policies > Sharing and set the OneDrive sharing level to 'New and existing guests' or more restrictive. This setting is separate from the SharePoint sharing level.",
"secure_score_impact": 5},
{"id":"SPO-004","title":"Guest Access Expiry Not Configured","module":"sharepoint","metric":"guest_access_expiry_configured","severity":"medium",
"threshold": lambda v: v is False,
"description":"External user (guest) access expiry is not configured. Shared links and guest accounts granted to contractors, partners, or clients do not automatically expire. Former employees of partner organisations, ex-contractors, and deprecated service accounts retain access indefinitely unless manually removed.",
"recommendation":"In SharePoint Admin Centre, go to Policies > Sharing > More external sharing settings. Enable 'Guest access to a site or OneDrive will expire automatically after this many days' and set a value appropriate for your business (30–90 days is typical). Also enable link expiry for anonymous sharing links.",
"secure_score_impact": 3},
# Over-Permissioned Apps
{"id":"APP-001","title":"High-Privilege OAuth Apps Detected","module":"security","metric":"high_privilege_app_count","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more third-party OAuth applications have been granted high-privilege permissions across the tenant. These apps have persistent access to data even after users log out, and are a common persistence mechanism used by attackers following account compromise.",
"recommendation":"Review all OAuth app permissions in Entra ID under Enterprise Applications. Remove or restrict apps that have unnecessary Graph permissions such as Mail.ReadWrite, Files.ReadWrite.All, or Directory.ReadWrite.All. Enable admin consent workflow to prevent users granting app permissions without approval.",
"secure_score_impact": 5},
# Alerting and Monitoring
{"id":"MON-001","title":"No Active Defender Alert Policies","module":"security","metric":"defender_alert_policy_count","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v == 0,
"description":"No Microsoft Defender alert policies are active. Without alerting, security incidents such as mass file downloads, impossible travel sign-ins, or malware detections will not be flagged to administrators in real time.",
"recommendation":"Enable Microsoft Defender for Office 365 and configure alert policies for high-severity events including suspicious inbox rules, mass file deletion, impossible travel, and malware detected. Ensure alerts are routed to a monitored mailbox or SIEM.",
"secure_score_impact": 5},
{"id":"SEC-003","title":"MFA Fatigue Protection Not Enabled","module":"security","metric":"mfa_number_matching_enabled","severity":"high",
"threshold": lambda v: v is False,
"description":"Microsoft Authenticator number matching and additional context (sign-in location and app name) are not enabled. Without these, users are vulnerable to MFA fatigue attacks where an attacker repeatedly sends push notifications until the user approves one.",
"recommendation":"Enable number matching and additional context in the Authenticator app settings under Entra ID Authentication Methods. This ensures users see the number displayed on screen before approving, making accidental approvals impossible.",
"secure_score_impact": 5},
{"id":"SEC-004","title":"Weak MFA Methods Enabled","module":"security","metric":"weak_auth_methods_enabled","severity":"medium",
"threshold": lambda v: v is True,
"description":"One or more weak authentication methods (SMS text, voice call, or email OTP) are enabled in the tenant. These methods can be intercepted via SIM swapping, call forwarding, or phishing, and are significantly less secure than the Microsoft Authenticator app or FIDO2 keys.",
"recommendation":"Disable SMS, voice call, and email OTP authentication methods in Entra ID under Authentication Methods policies. Migrate users to Microsoft Authenticator app with number matching, or FIDO2 security keys for highest assurance.",
"secure_score_impact": 8},
{"id":"SEC-005","title":"Users Can Consent to Apps Without Admin Approval","module":"security","metric":"user_consent_unrestricted","severity":"high",
"threshold": lambda v: v is True,
"description":"Users are permitted to grant OAuth application permissions to access company data without administrator approval. This allows malicious or over-permissioned apps to gain access to email, files, and other sensitive data simply by convincing a user to click Accept.",
"recommendation":"Restrict user consent to apps in Entra ID under Enterprise Applications > Consent and Permissions. Set to admin consent required, and enable the admin consent workflow so users can request access through an approved process.",
"secure_score_impact": 5},
# Intune
{"id":"MDM-001","title":"Low Device Compliance","module":"intune","metric":"intune_compliance_percentage","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v < 80,
"description":"Fewer than 80% of managed devices are compliant. Non-compliant devices may lack encryption or current patches.",
"recommendation":"Review non-compliant devices in Intune portal. Identify common failures and remediate. Consider blocking non-compliant device access to M365.",
"secure_score_impact": 5},
{"id":"MDM-002","title":"No Compliance Policies Configured","module":"intune","metric":"intune_compliance_policy_count","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v == 0,
"description":"No Intune device compliance policies are in place. Devices cannot be evaluated for compliance.",
"recommendation":"Create compliance policies for each device platform (Windows, iOS, Android) covering OS version, encryption, and antivirus requirements.",
"secure_score_impact": 8},
# New v1.2 findings
{"id":"ID-006","title":"Risky Users Not Reviewed","module":"identity","metric":"risky_users_count","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more users are flagged as high or medium risk by Entra ID Identity Protection and have not been remediated or dismissed. Risky users indicate potential compromised accounts.",
"recommendation":"Review risky users in Entra ID > Protection > Risky users. Require password reset or MFA re-registration for at-risk accounts. Investigate the risk events behind each flagged user.",
"secure_score_impact": 5},
{"id":"ID-007","title":"No Emergency Access Account Detected","module":"identity","metric":"emergency_access_exists","severity":"high",
"threshold": lambda v: v is False,
"description":"No break-glass (emergency access) account was detected. Without an emergency access account, a misconfigured Conditional Access policy or MFA outage could lock administrators out of the tenant.",
"recommendation":"Create at least two emergency access accounts. Exclude them from all CA policies. Store credentials securely offline. Monitor for any sign-in activity on these accounts as an indicator of compromise.",
"secure_score_impact": 3},
{"id":"SEC-006","title":"No Microsoft Sentinel Connected","module":"security","metric":"sentinel_connected","severity":"medium",
"threshold": lambda v: v is False,
"description":"Microsoft Sentinel does not appear to be connected or generating security alerts. Without a SIEM, threats across M365 services may not be correlated or retained for investigation.",
"recommendation":"Deploy Microsoft Sentinel and connect the Microsoft 365 Defender data connector. Configure analytics rules for high-priority scenarios and set up a regular alert review process.",
"secure_score_impact": 3},
{"id":"EXO-004","title":"DMARC Not Configured","module":"exchange","metric":"dmarc_configured","severity":"high",
"threshold": lambda v: v is False,
"description":"DMARC is not configured on the primary domain. Without DMARC, attackers can spoof your domain in phishing emails, impersonating your organisation to external recipients.",
"recommendation":"Publish a DMARC TXT record at _dmarc.yourdomain.com. Start with p=none for monitoring, then progress to p=quarantine and p=reject once SPF and DKIM are confirmed working.",
"secure_score_impact": 5},
{"id":"EXO-005","title":"SPF or DKIM Not Configured","module":"exchange","metric":"spf_dkim_configured","severity":"high",
"threshold": lambda v: v is False,
"description":"SPF or DKIM email authentication is not fully configured on the primary domain. Without both controls, outbound emails may be rejected by recipients and the domain can be spoofed.",
"recommendation":"Ensure an SPF TXT record exists for your domain. Enable DKIM signing in Exchange Online Admin > Email authentication. Both must pass before DMARC enforcement is safe to enable.",
"secure_score_impact": 5},
{"id":"EXO-006","title":"Zero-Hour Auto Purge (ZAP) Not Fully Enabled","module":"exchange","metric":"zap_fully_enabled","severity":"high",
"threshold": lambda v: v is False,
"description":"Zero-Hour Auto Purge (ZAP) is not fully enabled for malware, phishing, or spam. ZAP retroactively removes emails already delivered to mailboxes when they are later identified as malicious. Without ZAP, emails that bypass initial filters remain in user mailboxes permanently — giving attackers a lasting foothold for credential theft, business email compromise, and malware delivery.",
"recommendation":"In the Microsoft 365 Defender portal, go to Email & Collaboration > Policies & Rules > Threat policies. Under Anti-malware, edit the default policy and ensure ZAP is enabled. Under Anti-spam, edit the default inbound policy and ensure both Phishing ZAP and Spam ZAP are enabled.",
"secure_score_impact": 4,
"tags": ["email", "defender", "zap", "malware", "phishing"]},
{"id":"MDM-003","title":"No Windows Update Ring Configured","module":"intune","metric":"update_ring_count","severity":"medium",
"threshold": lambda v: isinstance(v,(int,float)) and v == 0,
"description":"No Windows Update for Business rings are configured in Intune. Without update rings, Windows devices may receive patches inconsistently or too late, leaving known vulnerabilities unpatched.",
"recommendation":"Create at least one Windows Update ring in Intune targeting Windows devices. Consider a Pilot ring and a Production ring with a deferral period to catch problematic updates before broad rollout.",
"secure_score_impact": 3},
{"id":"MDM-004","title":"BitLocker Not Enforced","module":"intune","metric":"bitlocker_enforced","severity":"high",
"threshold": lambda v: v is False,
"description":"BitLocker disk encryption does not appear to be required by Intune compliance or configuration policies. Devices without encryption expose all data if lost or stolen.",
"recommendation":"Create an Intune device configuration profile enabling BitLocker on Windows devices. Add a compliance policy condition requiring device encryption, and block non-compliant devices from accessing M365.",
"secure_score_impact": 8},
{"id":"MDM-005","title":"No Mobile Device Compliance Policy","module":"intune","metric":"mobile_compliance_policy_exists","severity":"high",
"threshold": lambda v: v is False,
"description":"No Intune compliance policy exists for iOS or Android devices. Mobile devices connecting to Microsoft 365 — including Exchange, Teams and SharePoint — are doing so with no compliance requirement. Compromised, jailbroken, or unmanaged personal devices can access the same data as fully managed corporate endpoints.",
"recommendation":"Create Intune compliance policies for iOS and Android covering minimum OS version, screen lock, device encryption, and jailbreak/root detection. Pair with a Conditional Access policy requiring compliant devices for mobile access to M365.",
"secure_score_impact": 5},
{"id":"MDM-006","title":"Defender for Endpoint Not Integrated with Intune","module":"intune","metric":"defender_mde_integration_enabled","severity":"medium",
"threshold": lambda v: v is False,
"description":"Microsoft Defender for Endpoint is not integrated with Intune via a Mobile Threat Defence connector. Without this integration, device risk signals from Defender — such as active malware, suspicious activity, or network attacks — are not available to Conditional Access. A compromised device can continue to access M365 resources even while Defender has flagged it.",
"recommendation":"In Intune, go to Endpoint security > Microsoft Defender for Endpoint and enable the connector. Set up device risk score conditions in your compliance policies. This routes Defender's real-time risk signals into CA so compromised devices are automatically blocked.",
"secure_score_impact": 4},
# Entra ID Deep Findings
{"id":"ENTRA-001","title":"High-Privilege App Registrations","module":"identity","metric":"high_priv_app_reg_count","severity":"critical",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more app registrations have been granted Critical or High risk Microsoft Graph application permissions. An attacker who compromises the application's credentials gains persistent, tenant-wide access that survives user password resets and MFA changes. These permissions are a common target for OAuth consent phishing and credential theft attacks.",
"recommendation":"Review all app registrations under Entra ID > App registrations. Remove or reduce permissions that are broader than required. Rotate credentials on any high-privilege app immediately. Enable admin consent workflow to prevent future over-privileged consent grants.",
"secure_score_impact": 5},
{"id":"ENTRA-002","title":"Expired App Registration Credentials","module":"identity","metric":"expired_cred_count","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more app registrations have credentials (client secrets or certificates) that have already expired. Expired credentials on high-privilege apps suggest the app may be unmanaged or abandoned — a common persistence mechanism left behind by former staff or attackers.",
"recommendation":"Go to Entra ID > App registrations and review all apps with expired credentials. Remove expired credentials immediately. If the app is no longer needed, delete the registration entirely. If still in use, rotate credentials and implement a credential rotation process.",
"secure_score_impact": 3},
{"id":"ENTRA-003","title":"App Registration Credentials Expiring Within 30 Days","module":"identity","metric":"expiring_cred_30d_count","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more app registrations have credentials expiring within 30 days. If not renewed, dependent services will fail to authenticate, potentially causing outages. Rushed credential rotation under time pressure increases the risk of errors.",
"recommendation":"Review and rotate expiring credentials immediately in Entra ID > App registrations > Certificates & secrets. Implement automated credential rotation or calendar reminders to avoid last-minute renewals.",
"secure_score_impact": 3},
{"id":"ENTRA-004","title":"App Registration Credentials Expiring Within 90 Days","module":"identity","metric":"expiring_cred_90d_count","severity":"medium",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more app registrations have credentials expiring within 31–90 days. Plan credential rotation now to avoid service disruption and rushed changes.",
"recommendation":"Schedule credential rotation for affected app registrations within the next 30 days. Review Entra ID > App registrations > Certificates & secrets and create replacement credentials before the current ones expire.",
"secure_score_impact": 2},
{"id":"ENTRA-005","title":"App Registration Credentials Set to Never Expire","module":"identity","metric":"never_expire_cred_count","severity":"medium",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more app registrations have credentials with no expiry date configured. Non-expiring credentials remain valid indefinitely, meaning a leaked secret provides persistent access with no natural rotation forcing function.",
"recommendation":"Replace never-expiring credentials with time-limited ones. Set expiry to 6–12 months and implement a rotation process. Go to Entra ID > App registrations > Certificates & secrets, add a new credential with an expiry, and remove the non-expiring one.",
"secure_score_impact": 3},
{"id":"ENTRA-006","title":"Unowned App Registrations","module":"identity","metric":"unowned_app_reg_count","severity":"medium",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more app registrations have no owner assigned. Without an owner, there is no accountable person to review permissions, rotate credentials, or respond if the app is compromised. Unowned apps are frequently abandoned and left with stale high-privilege permissions.",
"recommendation":"Assign an owner to every app registration in Entra ID > App registrations > [App] > Owners. Where no owner can be identified, review whether the app is still in use and delete it if not.",
"secure_score_impact": 2},
{"id":"ENTRA-007","title":"Multi-Tenant App Registrations","module":"identity","metric":"multitenant_app_reg_count","severity":"medium",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more app registrations are configured as multi-tenant, meaning users from any external Entra ID tenant can sign in or consent to the app. If this is not intentional, it expands the attack surface beyond your organisation.",
"recommendation":"Review multi-tenant app registrations in Entra ID > App registrations. If multi-tenant access is not required, change Supported account types to 'Accounts in this organizational directory only'. For legitimate multi-tenant apps, ensure publisher verification is complete.",
"secure_score_impact": 2},
{"id":"ENTRA-008","title":"Implicit Grant Flow Enabled on App Registrations","module":"identity","metric":"implicit_grant_app_count","severity":"medium",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more app registrations have implicit grant flow enabled (ID token or access token issuance). Implicit flow returns tokens in browser redirect URLs, making them susceptible to leakage via browser history, referrer headers, and cross-site scripting attacks. Microsoft recommends disabling implicit flow for all applications.",
"recommendation":"Go to Entra ID > App registrations > [App] > Authentication and uncheck both 'ID tokens' and 'Access tokens' under Implicit grant and hybrid flows. Migrate to the Authorization Code flow with PKCE for public clients.",
"secure_score_impact": 3},
{"id":"ENTRA-009","title":"Service Principals with High-Privilege Directory Roles","module":"identity","metric":"priv_service_principal_count","severity":"critical",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more service principals (enterprise applications) have been assigned high-privilege Entra ID directory roles such as Global Administrator or Application Administrator. A service principal with admin roles is a non-interactive backdoor — an attacker who obtains its credentials gains admin-level access without triggering user sign-in alerts or MFA prompts.",
"recommendation":"Go to Entra ID > Roles and administrators and review all high-privilege role assignments. Remove service principals from privileged roles unless there is a documented, audited business requirement. Use least-privilege roles (e.g., Application.ReadWrite.OwnedBy) where possible.",
"secure_score_impact": 5},
{"id":"ENTRA-010","title":"Managed Identities with High-Privilege Directory Roles","module":"identity","metric":"priv_managed_identity_count","severity":"high",
"threshold": lambda v: isinstance(v,(int,float)) and v > 0,
"description":"One or more managed identities have been assigned high-privilege Entra ID directory roles. Managed identities granted admin roles can be exploited by any workload running under that identity — a compromised Azure VM or Function App with a privileged managed identity can take administrative actions across the tenant.",
"recommendation":"Go to Entra ID > Roles and administrators and review managed identity role assignments. Remove high-privilege roles from managed identities and assign only the minimum permissions required for each workload.",
"secure_score_impact": 3},
]
FINDINGS_LIBRARY = build_findings_library()
METRIC_DISPLAY = {
"mfa_percentage": {"label":"MFA Coverage", "format":"{}%", "desc":"Percentage of users with MFA registered"},
"global_admin_count": {"label":"Global Administrators", "format":"{}", "desc":"Number of users with Global Admin role"},
"pim_enabled": {"label":"Just-in-Time Admin Access (PIM)", "format":"{}", "desc":"Whether Privileged Identity Management is active"},
"guest_user_count": {"label":"Guest Accounts", "format":"{}", "desc":"Number of external guest users in the tenant"},
"unassigned_licence_percentage": {"label":"Unused Licences", "format":"{}%", "desc":"Percentage of purchased licences not assigned"},
"secure_score_percentage": {"label":"Microsoft Secure Score", "format":"{}%", "desc":"Microsofts own security configuration score"},
"security_defaults_enabled": {"label":"Security Defaults Enabled", "format":"{}", "desc":"Whether Microsoft baseline security defaults are on"},
"ca_enabled_policy_count": {"label":"Conditional Access Policies", "format":"{}", "desc":"Number of active Conditional Access policies"},
"legacy_auth_blocked": {"label":"Legacy Authentication Blocked", "format":"{}", "desc":"Whether old-style auth protocols are blocked"},
"external_forwarding_blocked": {"label":"External Email Forwarding Blocked","format":"{}", "desc":"Whether auto-forwarding to external addresses is blocked"},
"mailbox_audit_enabled_percentage":{"label":"Mailbox Audit Coverage", "format":"{}%", "desc":"Percentage of mailboxes with audit logging enabled"},
"antiphish_intelligence_enabled": {"label":"Anti-Phishing Intelligence", "format":"{}", "desc":"Whether mailbox intelligence protects against impersonation"},
"teams_external_access_restricted":{"label":"Teams External Access Restricted","format":"{}", "desc":"Whether Teams federation is restricted to approved domains"},
"teams_consumer_access_blocked": {"label":"Teams Consumer Access Blocked", "format":"{}", "desc":"Whether personal Teams accounts are blocked"},
"teams_anon_meeting_join_enabled": {"label":"Anonymous Meeting Join", "format":"{}", "desc":"Whether unauthenticated users can join Teams meetings"},
"teams_third_party_apps_allowed": {"label":"Third-Party Apps Unrestricted", "format":"{}", "desc":"Whether all third-party Teams store apps are allowed"},
"spo_sharing_level": {"label":"SharePoint External Sharing", "format":"{}", "desc":"External sharing setting for SharePoint and OneDrive"},
"spo_legacy_auth": {"label":"SharePoint Legacy Auth Enabled", "format":"{}", "desc":"Whether old authentication is enabled in SharePoint"},
"onedrive_sharing_level": {"label":"OneDrive External Sharing", "format":"{}", "desc":"External sharing setting for OneDrive for Business"},
"guest_access_expiry_configured": {"label":"Guest Access Expiry", "format":"{}", "desc":"Whether external user access expires automatically"},
"intune_compliance_percentage": {"label":"Device Compliance Rate", "format":"{}%", "desc":"Percentage of managed devices meeting compliance policy"},
"intune_compliance_policy_count": {"label":"Device Compliance Policies", "format":"{}", "desc":"Number of Intune compliance policies configured"},
"intune_config_policy_count": {"label":"Device Config Policies", "format":"{}", "desc":"Number of Intune device configuration profiles"},
"high_privilege_app_count": {"label":"High-Privilege OAuth Apps", "format":"{}", "desc":"Apps with dangerous tenant-wide permissions"},
"defender_alert_policy_count": {"label":"Defender Alert Policies", "format":"{}", "desc":"Number of active Microsoft Defender alert policies"},
"mfa_number_matching_enabled": {"label":"MFA Fatigue Protection", "format":"{}", "desc":"Whether Authenticator number matching is enabled"},
"weak_auth_methods_enabled": {"label":"Weak MFA Methods Active", "format":"{}", "desc":"Whether SMS, voice, or email OTP auth is enabled"},
"user_consent_unrestricted": {"label":"Users Can Consent to Apps", "format":"{}", "desc":"Whether users can grant app permissions without admin approval"},
"teams_email_into_channel": {"label":"Teams Email-to-Channel", "format":"{}", "desc":"Whether external emails can be sent into Teams channels"},
"risky_users_count": {"label":"Risky Users (High/Medium)", "format":"{}", "desc":"Users flagged as high or medium risk by Identity Protection"},
"emergency_access_exists": {"label":"Emergency Access Account", "format":"{}", "desc":"Whether a break-glass account is detectable in the tenant"},
"sentinel_connected": {"label":"Microsoft Sentinel Connected", "format":"{}", "desc":"Whether Sentinel appears to be active and generating alerts"},
"dmarc_configured": {"label":"DMARC Configured", "format":"{}", "desc":"Whether a DMARC record exists for the primary domain"},
"spf_dkim_configured": {"label":"SPF and DKIM Configured", "format":"{}", "desc":"Whether SPF and DKIM are both set up for the primary domain"},
"zap_fully_enabled": {"label":"Zero-Hour Auto Purge (ZAP)", "format":"{}", "desc":"Whether ZAP is enabled for malware, phishing and spam"},
"zap_malware_enabled": {"label":"ZAP — Malware", "format":"{}", "desc":"Whether ZAP is enabled in the malware filter policy"},
"zap_phish_enabled": {"label":"ZAP — Phishing", "format":"{}", "desc":"Whether ZAP is enabled for phishing in the content filter"},
"zap_spam_enabled": {"label":"ZAP — Spam", "format":"{}", "desc":"Whether ZAP is enabled for spam in the content filter"},
"update_ring_count": {"label":"Windows Update Rings", "format":"{}", "desc":"Number of Windows Update for Business rings in Intune"},
"bitlocker_enforced": {"label":"BitLocker Enforced", "format":"{}", "desc":"Whether BitLocker is required by Intune policies"},
"mobile_compliance_policy_exists": {"label":"Mobile Compliance Policy", "format":"{}", "desc":"Whether an iOS or Android compliance policy exists in Intune"},
"defender_mde_integration_enabled":{"label":"Defender MDE Integration", "format":"{}", "desc":"Whether Defender for Endpoint is connected to Intune"},
"mfa_all_users_ca_policy": {"label":"MFA for All Users (CA)", "format":"{}", "desc":"Whether a CA policy enforces MFA broadly for all users"},
"high_priv_app_reg_count": {"label":"High-Privilege App Registrations", "format":"{}", "desc":"Apps with Critical or High risk Graph permissions"},
"expired_cred_count": {"label":"Expired App Credentials", "format":"{}", "desc":"App registrations with expired credentials"},
"expiring_cred_30d_count": {"label":"Credentials Expiring (≤30 days)", "format":"{}", "desc":"App registrations with credentials expiring within 30 days"},
"expiring_cred_90d_count": {"label":"Credentials Expiring (31–90 days)","format":"{}", "desc":"App registrations with credentials expiring within 31–90 days"},
"never_expire_cred_count": {"label":"Never-Expiring Credentials", "format":"{}", "desc":"App registrations with credentials set to never expire"},
"unowned_app_reg_count": {"label":"Unowned App Registrations", "format":"{}", "desc":"App registrations with no owner assigned"},
"multitenant_app_reg_count": {"label":"Multi-Tenant App Registrations", "format":"{}", "desc":"App registrations accessible from any Entra tenant"},
"implicit_grant_app_count": {"label":"Implicit Grant Apps", "format":"{}", "desc":"Apps with implicit ID/access token issuance enabled"},
"priv_service_principal_count": {"label":"Privileged Service Principals", "format":"{}", "desc":"Service principals with high-privilege directory roles"},
"priv_managed_identity_count": {"label":"Privileged Managed Identities", "format":"{}", "desc":"Managed identities with high-privilege directory roles"},
}
# ─────────────────────────────────────────────────────────────
# SCRIPT RUNNER
# ─────────────────────────────────────────────────────────────
# Map module names → script filenames
MODULE_SCRIPTS = {
"identity": "Get-IdentityMetrics.ps1",
"security": "Get-SecurityMetrics.ps1",
"exchange": "Get-ExchangeMetrics.ps1",
"teams": "Get-TeamsMetrics.ps1",
"sharepoint": "Get-SharePointMetrics.ps1",
"intune": "Get-IntuneMetrics.ps1",
}
# Modules that ALWAYS use interactive login (no app-only support)
INTERACTIVE_ONLY_MODULES = {"exchange", "teams", "sharepoint"}
def build_ps_args(module, auth):
"""Build the PowerShell parameter list for a given module and auth config."""
auth_method = auth.get("authMethod", "interactive")
environment = auth.get("environment", "commercial").lower()
args = []
# Government cloud endpoint overrides
# GCC uses the same endpoints as Commercial — no change needed
# GCCH uses graph.microsoft.us / login.microsoftonline.us
# DoD uses dod-graph.microsoft.us / login.microsoftonline.us
if environment == "gcch":
graph_endpoint = "https://graph.microsoft.us"
login_endpoint = "https://login.microsoftonline.us"
elif environment == "dod":
graph_endpoint = "https://dod-graph.microsoft.us"
login_endpoint = "https://login.microsoftonline.us"
else:
# Commercial and GCC both use standard endpoints
graph_endpoint = "https://graph.microsoft.com"
login_endpoint = "https://login.microsoftonline.com"
if auth_method == "appreg" and module not in INTERACTIVE_ONLY_MODULES:
args += ["-AuthMethod", "AppReg"]
args += ["-TenantId", auth.get("tenantId", "")]
args += ["-ClientId", auth.get("clientId", "")]
args += ["-ClientSecret", auth.get("clientSecret", "")]
args += ["-GraphEndpoint", graph_endpoint]
args += ["-LoginEndpoint", login_endpoint]
elif auth_method == "certificate" and module not in INTERACTIVE_ONLY_MODULES:
args += ["-AuthMethod", "Certificate"]
args += ["-TenantId", auth.get("tenantId", "")]
args += ["-ClientId", auth.get("clientId", "")]
args += ["-CertThumbprint", auth.get("certThumbprint", "")]
args += ["-GraphEndpoint", graph_endpoint]
args += ["-LoginEndpoint", login_endpoint]
else:
# Interactive auth (also fallback for cert/appreg on interactive-only modules)
args += ["-AuthMethod", "Interactive"]
tenant_id = auth.get("tenantId", "")
if tenant_id:
args += ["-TenantId", tenant_id]
# Pass environment to all modules so Exchange/Teams/SPO can switch endpoints
args += ["-Environment", environment]
# SharePoint admin URL for SPO module
if module == "sharepoint":
args += ["-SpAdminUrl", auth.get("spAdminUrl", "")]
return args
def run_script(script_name, ps_args):
"""
Execute a PowerShell script and return parsed JSON output.
App Registration: runs silently, captures stdout directly.
Interactive: runs allowing popup windows, filters WARNING lines before JSON parsing.
"""
script_path = os.path.join(SCRIPTS_DIR, script_name)
if not os.path.exists(script_path):
return None, f"Script not found: {script_name}"
# Do NOT use -NonInteractive - it blocks login popups for interactive auth
cmd = [
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", script_path
] + ps_args
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300
)
stdout = result.stdout.strip()
# Filter WARNING and INFO lines - find the JSON output line
json_line = None
for line in reversed(stdout.splitlines()):
line = line.strip()
if line.startswith("{") and line.endswith("}"):
json_line = line
break
# If script failed and no JSON found, return error
if result.returncode != 0 and not json_line:
err = result.stderr.strip() or stdout[:300] or f"Script exited with code {result.returncode}"
return None, err
if not json_line:
err = result.stderr.strip() or "Script produced no JSON output"
return None, err
data = json.loads(json_line)
return data, None
except subprocess.TimeoutExpired:
return None, "Script timed out after 300 seconds"
except json.JSONDecodeError as e:
stderr = result.stderr.strip() if result else ""
return None, f"Invalid JSON from script: {e}. stderr: {stderr[:300]}"
except Exception as e:
return None, str(e)
# ─────────────────────────────────────────────────────────────
# EVALUATION & SCORING
# ─────────────────────────────────────────────────────────────
def evaluate_findings(all_metrics):
triggered = []
for f in FINDINGS_LIBRARY:
metric = f["metric"]
if metric not in all_metrics:
continue
value = all_metrics[metric]
try:
try:
triggered_flag = f["threshold"](value, all_metrics)
except TypeError:
triggered_flag = f["threshold"](value)
if triggered_flag:
triggered.append({
"id": f["id"], "title": f["title"], "module": f["module"],
"metric": metric, "severity": f["severity"],
"description": f["description"], "recommendation": f["recommendation"],
"observed_value": value,
"secure_score_impact": f.get("secure_score_impact", 0)
})
except Exception:
pass
return triggered
def calculate_score(findings):
"""
Score out of 100. Deductions per finding with caps per severity band
so a tenant with many findings still gets a meaningful score.
Critical: -8 each, max -32 (4+ critical = worst band)
High: -5 each, max -20
Medium: -3 each, max -12
Low: -1 each, max -4
Floor: 10 (even the worst tenant shows a number)
"""
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in findings:
sev = f.get("severity", "low")
if sev in counts:
counts[sev] += 1
penalty = (
min(counts["critical"] * 8, 32) +
min(counts["high"] * 5, 20) +
min(counts["medium"] * 3, 12) +
min(counts["low"] * 1, 4)
)
return max(10, 100 - penalty)
def format_metric(key, value):
config = METRIC_DISPLAY.get(key, {"label": key, "format": "{}"})
try:
display = config["format"].format(value)
except Exception:
display = str(value)
status = "good"
if isinstance(value, bool):
# For flags where True = good
good_when_true = {"pim_enabled", "security_defaults_enabled", "legacy_auth_blocked",
"external_forwarding_blocked", "antiphish_intelligence_enabled",
"teams_external_access_restricted", "teams_consumer_access_blocked",
"mfa_number_matching_enabled",
"zap_fully_enabled", "zap_malware_enabled", "zap_phish_enabled", "zap_spam_enabled",
"guest_access_expiry_configured", "mobile_compliance_policy_exists",
"defender_mde_integration_enabled", "mfa_all_users_ca_policy"}
# Flags where True = bad
bad_when_true_extra = {"weak_auth_methods_enabled", "user_consent_unrestricted", "teams_email_into_channel",
"teams_anon_meeting_join_enabled", "teams_third_party_apps_allowed"}
# For flags where False = good
bad_when_true = {"spo_legacy_auth"}
if key in bad_when_true or key in bad_when_true_extra:
status = "bad" if value else "good"
else:
status = "good" if value else "bad"
elif isinstance(value, (int, float)):
percentage_good_high = {"mfa_percentage", "secure_score_percentage",
"mailbox_audit_enabled_percentage", "intune_compliance_percentage"}
percentage_good_low = {"unassigned_licence_percentage"}
count_good_low = {"global_admin_count", "guest_user_count"}
count_good_high = {"ca_enabled_policy_count", "intune_compliance_policy_count", "defender_alert_policy_count", "intune_config_policy_count"}
count_good_zero = {"high_privilege_app_count", "risky_users_count",
"high_priv_app_reg_count", "expired_cred_count",
"expiring_cred_30d_count", "expiring_cred_90d_count",
"never_expire_cred_count", "unowned_app_reg_count",
"multitenant_app_reg_count", "implicit_grant_app_count",
"priv_service_principal_count", "priv_managed_identity_count"}
count_good_nonzero = {"update_ring_count"}
if key in percentage_good_high:
status = "good" if value >= 90 else ("warn" if value >= 70 else "bad")
elif key in percentage_good_low:
status = "good" if value <= 10 else ("warn" if value <= 20 else "bad")
elif key in count_good_low:
status = "good" if value <= 2 else ("warn" if value <= 4 else "bad")
elif key in count_good_high:
status = "good" if value >= 3 else ("warn" if value >= 1 else "bad")
elif key in count_good_zero:
status = "good" if value == 0 else ("warn" if value <= 2 else "bad")
elif key in count_good_nonzero:
status = "good" if value >= 1 else "bad"
elif isinstance(value, str):
bad_values = {"ExternalUserAndGuestSharing", "anyone"}
warn_values = {"ExternalUserSharingOnly", "new_and_existing"}
if value in bad_values: status = "bad"
elif value in warn_values: status = "warn"
# Convert True/False to friendly labels
if isinstance(value, bool):
friendly_map = {
"pim_enabled": ("Active", "Not Active"),
"security_defaults_enabled": ("Enabled", "Disabled"),
"legacy_auth_blocked": ("Blocked", "Not Blocked"),
"external_forwarding_blocked": ("Blocked", "Not Blocked"),
"antiphish_intelligence_enabled": ("Enabled", "Disabled"),
"teams_external_access_restricted": ("Restricted", "Open"),
"teams_consumer_access_blocked": ("Blocked", "Allowed"),
"spo_legacy_auth": ("Enabled", "Disabled"),
"mfa_number_matching_enabled": ("Enabled", "Disabled"),
"weak_auth_methods_enabled": ("Yes - Review", "No"),
"user_consent_unrestricted": ("Yes - Review", "Restricted"),
"teams_email_into_channel": ("Allowed", "Blocked"),
"emergency_access_exists": ("Detected", "Not Detected"),
"sentinel_connected": ("Connected", "Not Connected"),
"dmarc_configured": ("Configured", "Not Configured"),
"spf_dkim_configured": ("Configured", "Not Configured"),
"bitlocker_enforced": ("Enforced", "Not Enforced"),
"zap_fully_enabled": ("Enabled", "Not Fully Enabled"),
"zap_malware_enabled": ("Enabled", "Disabled"),
"zap_phish_enabled": ("Enabled", "Disabled"),
"zap_spam_enabled": ("Enabled", "Disabled"),
"teams_anon_meeting_join_enabled": ("Allowed", "Blocked"),
"teams_third_party_apps_allowed": ("Allowed", "Restricted"),
"guest_access_expiry_configured": ("Configured", "Not Configured"),
"mobile_compliance_policy_exists": ("Exists", "Not Found"),
"defender_mde_integration_enabled": ("Connected", "Not Connected"),
"mfa_all_users_ca_policy": ("Policy Exists", "No Policy Found"),
}
if key in friendly_map:
display = friendly_map[key][0] if value else friendly_map[key][1]
# SharePoint sharing level friendly labels
spo_labels = {
"ExternalUserAndGuestSharing": "Anyone (Unrestricted)",
"ExternalUserSharingOnly": "New and Existing Guests",
"ExistingExternalUserSharingOnly": "Existing Guests Only",
"Disabled": "No External Sharing",
"Unknown": "Could not retrieve",
}
if key == "spo_sharing_level":
display = spo_labels.get(str(value), str(value))
desc = config.get("desc", "")
return {"label": config["label"], "value": display, "status": status, "sub": desc}
def save_csvs(client_name, all_metrics, findings):
date_str = datetime.date.today().strftime("%Y%m%d")
safe = client_name.replace(" ", "_")
with open(os.path.join(OUTPUT_DIR, f"TenantMetrics_{safe}_{date_str}.csv"), "w", newline="") as f:
w = csv.writer(f); w.writerow(["Metric","Value"])
for k, v in all_metrics.items(): w.writerow([k, v])
fields = ["id","title","module","metric","severity","observed_value","recommendation"]
with open(os.path.join(OUTPUT_DIR, f"TriggeredFindings_{safe}_{date_str}.csv"), "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields); w.writeheader()
for fi in findings: w.writerow({k: fi.get(k,"") for k in fields})
def save_session(session_data):
"""Save full assessment session as JSON for later reload."""
client_name = session_data.get("orgName", session_data.get("clientName", "Unknown"))
assess_date = session_data.get("assessDate", datetime.date.today().isoformat())
safe = client_name.replace(" ", "_").replace("/", "-")
date_str = assess_date.replace("-", "")
# Add timestamp to avoid overwriting same-day runs
timestamp = datetime.datetime.now().strftime("%H%M%S")
filename = f"Session_{safe}_{date_str}_{timestamp}.json"
filepath = os.path.join(OUTPUT_DIR, filename)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(session_data, f, ensure_ascii=False, indent=2)
return filename
# ─────────────────────────────────────────────────────────────
# ROUTES
# ─────────────────────────────────────────────────────────────
# Read version from VERSION file — never hardcode so updates always reflect correctly
_ver_file = os.path.join(BASE_DIR, "VERSION")
CURRENT_VERSION = open(_ver_file).read().strip() if os.path.exists(_ver_file) else "1.4.0"
VERSION_URL = "https://raw.githubusercontent.com/malcolmmcdonald1982/M365-Assessment-Toolkit/main/VERSION"
RELEASES_URL = "https://github.com/malcolmmcdonald1982/M365-Assessment-Toolkit/releases"
@app.route("/status", methods=["GET"])
def status():
return jsonify({"status": "online", "version": CURRENT_VERSION,
"findings_loaded": len(FINDINGS_LIBRARY), "scripts_dir": SCRIPTS_DIR})
@app.route("/check-update", methods=["GET"])
def check_update():
"""Check GitHub for a newer version."""
try:
req = urllib.request.Request(VERSION_URL,
headers={"User-Agent": "M365-Assessment-Toolkit"})
with urllib.request.urlopen(req, timeout=5) as r:
latest = r.read().decode().strip()
update_available = latest != CURRENT_VERSION
return jsonify({
"current": CURRENT_VERSION,
"latest": latest,
"update_available": update_available,
"releases_url": RELEASES_URL
})
except Exception as e:
return jsonify({
"current": CURRENT_VERSION,
"latest": CURRENT_VERSION,
"update_available": False,
"error": str(e)
})
@app.route("/apply-update", methods=["POST"])
def apply_update():
"""Run the local update.ps1 script to pull latest files from GitHub."""
update_script = os.path.join(BASE_DIR, "update.ps1")
if not os.path.exists(update_script):
return jsonify({"success": False, "error": "update.ps1 not found"}), 500
try:
result = subprocess.run(
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", update_script, "-Force"],
capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
return jsonify({"success": False, "error": result.stderr.strip() or result.stdout.strip()})
return jsonify({"success": True, "output": result.stdout.strip()})
except subprocess.TimeoutExpired:
return jsonify({"success": False, "error": "Update timed out after 120 seconds"})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
@app.route("/run", methods=["POST"])
def run_assessment():
body = request.get_json()
client_name = body.get("orgName", body.get("clientName", "Unknown"))
modules = body.get("modules", [])
auth = {k: body.get(k,"") for k in
["authMethod","tenantId","clientId","clientSecret","certThumbprint","spAdminUrl","environment"]}
log = []
all_metrics = {}
def L(msg, t="info"):
log.append({"message": msg, "type": t})
print(f"[{t.upper()}] {msg}", flush=True)
L(f"Assessment started — {client_name}")
L(f"Auth method: {auth['authMethod']}")
for module in modules:
script = MODULE_SCRIPTS.get(module)
if not script:
L(f"Unknown module: {module}", "warn"); continue
is_interactive_only = module in INTERACTIVE_ONLY_MODULES
if auth["authMethod"] == "interactive" or is_interactive_only:
effective_auth = "interactive"
elif auth["authMethod"] == "certificate":
effective_auth = "certificate"
else:
effective_auth = "appreg"
if is_interactive_only and auth["authMethod"] == "appreg":
L(f"{module}: App Reg not supported for this workload — using interactive login", "warn")
L(f"Running: {script} [{effective_auth}]")
ps_args = build_ps_args(module, auth)
metrics, error = run_script(script, ps_args)
if error:
L(f"{module} failed: {error}", "error")
elif metrics:
all_metrics.update(metrics)
L(f"{module} complete — {len(metrics)} metrics collected", "success")
else:
L(f"{module} returned no data", "warn")
# Derive composite metrics from raw values
if any(k in all_metrics for k in ("zap_malware_enabled", "zap_phish_enabled", "zap_spam_enabled")):
all_metrics["zap_fully_enabled"] = bool(
all_metrics.get("zap_malware_enabled", False) and
all_metrics.get("zap_phish_enabled", False) and
all_metrics.get("zap_spam_enabled", False)
)
findings = evaluate_findings(all_metrics)
score = calculate_score(findings)
display_metrics = [format_metric(k, v) for k, v in all_metrics.items()]
L(f"Findings: {len(findings)} triggered")
L(f"Score: {score}/100")
try:
save_csvs(client_name, all_metrics, findings)
L("CSVs saved to /output", "success")
except Exception as e:
L(f"CSV save failed: {e}", "warn")
assess_date = datetime.date.today().isoformat()
# Check if a remediation log exists for this client
safe_client = client_name.replace(" ", "_").replace("/", "-")
rem_log_path = os.path.join(OUTPUT_DIR, f"RemediationLog_{safe_client}.json")
rem_log = []
if os.path.exists(rem_log_path):
try:
with open(rem_log_path, "r", encoding="utf-8") as f:
rem_log = json.load(f)
except Exception:
pass
session = {
"orgName": client_name, "clientName": client_name,
"authMethod": auth["authMethod"],
"assessDate": assess_date,
"score": score,
"metrics": display_metrics,
"findings": findings,
"rawMetrics": all_metrics,
"modulesRun": len(modules),
"log": log,
"savedAt": datetime.datetime.now().isoformat(),
"toolVersion": "1.2.0",
"remediationLog": rem_log,
}
try:
saved_file = save_session(session)
L(f"Session saved: {saved_file}", "success")
session["savedFile"] = saved_file
except Exception as e:
L(f"Session save failed: {e}", "warn")
return jsonify(session)
@app.route("/report", methods=["POST"])
def report_meta():
body = request.get_json()
return jsonify({"status":"ready","modulesRun": body.get("modulesRun",0),
"findingsCount": len(body.get("findings",[]))})
@app.route("/download", methods=["POST"])
def download_report():
"""
Generate a professional colour-coded .docx report using the Node.js generator.
Requires Node.js and: npm install docx (run once in the tool folder)
"""
body = request.get_json()
client_name = body.get("orgName", body.get("clientName", "Organisation"))
assess_date = body.get("assessDate", str(datetime.date.today()))
safe_name = client_name.replace(" ", "_").replace("/", "-")
filename = f"M365_Assessment_{safe_name}_{assess_date.replace('-', '')}.docx"
report_path = os.path.join(REPORTS_DIR, filename)
json_path = os.path.join(REPORTS_DIR, f"_tmp_{safe_name}.json")
generator = os.path.join(BASE_DIR, "generate-report.js")
if not os.path.exists(generator):
return jsonify({"error": "generate-report.js not found. Place it in the same folder as backend.py."}), 500
# Write assessment data to a temp JSON file for the Node script
with open(json_path, "w", encoding="utf-8") as f:
json.dump(body, f, ensure_ascii=False)
try:
result = subprocess.run(
["node", generator, json_path, report_path],
capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
error_detail = result.stderr.strip() or result.stdout.strip()
return jsonify({"error": f"Report generator failed: {error_detail}"}), 500
except FileNotFoundError:
return jsonify({"error": "Node.js not found. Install from https://nodejs.org"}), 500
finally:
if os.path.exists(json_path):
os.remove(json_path)
if not os.path.exists(report_path):
return jsonify({"error": "Report file was not created — check Node.js and docx module are installed."}), 500
return send_file(
report_path,
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
as_attachment=True,
download_name=filename
)
@app.route("/download-remediation", methods=["POST"])
def download_remediation_report():
"""
Generate a separate Remediation Report document.
Only available when a remediation log exists for the client.
"""
body = request.get_json()
client_name = body.get("orgName", body.get("clientName", "Organisation"))
assess_date = body.get("assessDate", str(datetime.date.today()))
rem_date = datetime.date.today().isoformat()
safe_name = client_name.replace(" ", "_").replace("/", "-")
filename = f"M365_RemediationReport_{safe_name}_{rem_date.replace('-','')}.docx"
report_path = os.path.join(REPORTS_DIR, filename)
json_path = os.path.join(REPORTS_DIR, f"_tmp_rem_{safe_name}.json")
generator = os.path.join(BASE_DIR, "generate-report.js")
print(f"[REMEDIATION REPORT] Organisation: {client_name}, Safe: {safe_name}", flush=True)
print(f"[REMEDIATION REPORT] Generator: {generator} exists={os.path.exists(generator)}", flush=True)
if not os.path.exists(generator):
return jsonify({"error": "generate-report.js not found"}), 500
# Always load from file — file is source of truth and includes rollbacks
# that may have happened after the frontend cached the session data
log_path = os.path.join(OUTPUT_DIR, f"RemediationLog_{safe_name}.json")
print(f"[REMEDIATION REPORT] Log path: {log_path} exists={os.path.exists(log_path)}", flush=True)
rem_log = []
if os.path.exists(log_path):
try:
with open(log_path, "r", encoding="utf-8") as f:
rem_log = json.load(f)
except Exception as e:
print(f"[REMEDIATION REPORT] Log read error: {e}", flush=True)
# Fall back to body if file missing
if not rem_log:
rem_log = body.get("remediationLog", [])
print(f"[REMEDIATION REPORT] Log entries: {len(rem_log)}", flush=True)
if not rem_log:
return jsonify({"error": "No remediation log found for this client. Complete at least one remediation before generating this report."}), 400
# Calculate after-remediation score
remediatedIds = {e["findingId"] for e in rem_log if e.get("action") == "remediate" and e.get("success")}
rolledBackIds = {e["findingId"] for e in rem_log if e.get("action") == "rollback" and e.get("success")}
netFixed = remediatedIds - rolledBackIds
openFindings = [f for f in body.get("findings", []) if f["id"] not in netFixed]
score_after = calculate_score(openFindings)
# Build data payload for remediation report
report_data = {
**body,
"remediationLog": rem_log,
"remediationDate": rem_date,
"scoreAfter": score_after,
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(report_data, f, ensure_ascii=False)
try:
result = subprocess.run(
["node", generator, json_path, report_path, "remediation"],
capture_output=True, text=True, timeout=60
)