SaaS Security Posture Management – automated security posture scanning for popular SaaS platforms against industry-standard benchmarks (CIS, NIST, etc.).
Initial targets:
- Microsoft 365 against CIS Microsoft 365 Foundations Benchmark v6.0.1
- Google Workspace against CIS Google Workspace Foundations Benchmark v1.3.0
Output: SARIF 2.1.0 JSON, compatible with GitHub Advanced Security, Azure DevOps, VS Code (SARIF Viewer), and any SARIF-aware toolchain.
SaaS Platform ──► BaseProvider.collect() ──► CollectedData
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Rule.check() Rule.check() Rule.check()
│ │ │
└───────────────┼───────────────┘
▼
ScanResult
│
SarifReporter
│
report.sarif.json
| Component | Responsibility |
|---|---|
BaseProvider |
Authenticate and collect all configuration data from a SaaS platform in one pass |
CollectedData |
Immutable snapshot of the target's configuration, keyed by data-source name |
BaseRule |
Evaluate one security control against CollectedData, return a Finding |
ScanEngine |
Orchestrate collection + rule evaluation, build ScanResult |
RuleRegistry |
Self-registration and auto-discovery of rule classes |
reporter.to_sarif() |
Convert ScanResult → SARIF 2.1.0 document |
Every rule is a Python class that:
- Inherits from
MS365RuleorGWSRule(both inheritBaseRule) - Defines a
metadata: RuleMetadataclass attribute capturing all CIS fields - Implements
async def check(self, data: CollectedData) -> Finding - Calls
@registry.ruledecorator to self-register
Finding statuses:
| Status | Meaning | SARIF kind |
|---|---|---|
PASS |
Control is compliant | pass |
FAIL |
Control is non-compliant | fail |
MANUAL |
Cannot be automated; human review required | open |
ERROR |
Rule evaluation raised an unexpected exception | review |
SKIPPED |
Prerequisites not met (missing data / license) | none |
Mirrors the CIS benchmark Recommendation Definition structure:
RuleMetadata(
id = "ms365-cis-1.1.1", # Unique rule identifier
title = "...", # Short title
section = "1.1 Users", # Benchmark section
benchmark = "CIS MS365 v6.0.1",
assessment_status = AssessmentStatus.AUTOMATED, # or MANUAL
profiles = [CISProfile.E3_L1, CISProfile.E5_L1],
severity = Severity.HIGH,
description = "...",
rationale = "...",
impact = "...",
audit_procedure = "...",
remediation = "...",
default_value = "...",
references = ["https://..."],
cis_controls = [CISControl(version="v8", control_id="5.4", ...)],
tags = ["identity", "admin"],
)The MS365 provider targets CIS Microsoft 365 Foundations Benchmark v6.0.1 across all 9 admin center sections:
| Section | Admin Center | Controls |
|---|---|---|
| 1 | Microsoft 365 admin center | Users, Groups, Settings |
| 2 | Microsoft 365 Defender | Email & collaboration, System |
| 3 | Microsoft Purview | Audit, DLP, Information Protection |
| 4 | Microsoft Intune | Device compliance, Enrollment |
| 5 | Microsoft Entra ID | Users, Devices, CA, MFA, ID Governance |
| 6 | Exchange admin center | Audit, Mail flow, Settings |
| 7 | SharePoint admin center | Policies, Settings |
| 8 | Microsoft Teams | Meetings, Messaging, Users |
| 9 | Microsoft Fabric | Tenant settings |
The scanner authenticates to Microsoft Graph using an Entra ID App Registration with app-only (client credentials) permissions. A Global Administrator or Privileged Role Administrator must complete the one-time setup below.
- Sign in to the Microsoft Entra admin center: https://entra.microsoft.com
- Navigate to Identity → Applications → App registrations.
- Click New registration.
- Fill in the form:
- Name:
accuknox-sspm(or any descriptive name) - Supported account types: Accounts in this organizational directory only (Single tenant)
- Redirect URI: leave blank
- Name:
- Click Register.
- On the overview page, copy and save:
- Application (client) ID → this is your
--client-id - Directory (tenant) ID → this is your
--tenant-id
- Application (client) ID → this is your
- In the app registration, go to Certificates & secrets → Client secrets.
- Click New client secret.
- Set a Description (e.g.
sspm-scanner) and choose an Expiry (recommended: 12 months; rotate before expiry). - Click Add.
- Immediately copy the secret
Value— it is only shown once. This is your--client-secret.
Tip: For production use, prefer a certificate over a client secret. Upload a self-signed certificate under Certificates & secrets → Certificates and pass the
.pempath instead of a secret.
-
In the app registration, go to API permissions → Add a permission → Microsoft Graph → Application permissions.
-
Search for and add each of the following permissions:
Permission Purpose User.Read.AllRead all user accounts and properties RoleManagement.Read.DirectoryRead directory role assignments Policy.Read.AllRead Conditional Access and other policies Directory.Read.AllRead directory objects (groups, devices, apps) Reports.Read.AllRead usage and activity reports SecurityEvents.Read.AllRead Defender secure score and security data Organization.Read.AllRead tenant organisation settings and domains Application.Read.AllRead registered applications and service principals AuditLog.Read.AllRead Entra ID sign-in and audit logs DeviceManagementConfiguration.Read.AllRead Intune device compliance policies DeviceManagementServiceConfig.Read.AllRead Intune enrollment restrictions InformationProtectionPolicy.Read.AllRead Purview sensitivity labels and DLP SharePointTenantSettings.Read.AllRead SharePoint tenant-level sharing settings AccessReview.Read.AllRead Identity Governance access review definitions RoleManagementPolicy.Read.DirectoryRead PIM role management policies -
Click Add permissions after selecting all of the above.
-
Click Grant admin consent for <your tenant>, then confirm.
All permissions should show a green Granted status.
Find your primary tenant domain:
- Microsoft 365 admin center → Settings → Domains, or
- Entra admin center → Identity → Overview (shown as Primary domain).
It typically looks like contoso.onmicrosoft.com or contoso.com.
This is your --tenant-domain.
Test that the credentials work before running a full scan:
# Quick test: fetch the tenant organisation object
curl -s -X POST \
"https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/token" \
-d "grant_type=client_credentials" \
-d "client_id=<CLIENT_ID>" \
-d "client_secret=<CLIENT_SECRET>" \
-d "scope=https://graph.microsoft.com/.default" \
| python3 -m json.tool | grep -E "access_token|error"A response containing "access_token" confirms the credentials are valid.
| Value | Where to find it | CLI flag / env var |
|---|---|---|
| Tenant ID | Entra app overview → Directory (tenant) ID | --tenant-id / SSPM_TENANT_ID |
| Client ID | Entra app overview → Application (client) ID | --client-id / SSPM_CLIENT_ID |
| Client Secret | Created in Step 2 (copy immediately) | --client-secret / SSPM_CLIENT_SECRET |
| Tenant Domain | Primary domain from Step 4 | --tenant-domain / SSPM_TENANT_DOMAIN |
- Principle of least privilege: only grant the permissions listed above.
Do not add
*.Writeor*.ReadWritepermissions — the scanner is read-only. - Restrict to your tenant: keep Supported account types as single-tenant.
- Rotate the client secret before expiry; set a calendar reminder.
- Audit usage: the app registration appears in Entra ID sign-in logs. Review it periodically for unexpected activity.
- Consider IP restriction: use Conditional Access Named Locations and a service principal policy to restrict token issuance to your scanner's IP range.
Note: Exchange Online and SharePoint Online controls that require PowerShell modules (EXO, PnP, Teams) return
MANUALorSKIPPEDfindings when Graph API equivalents are unavailable.
The GWS provider targets CIS Google Workspace Foundations Benchmark v1.3.0:
| Section | Area | Automated | Manual |
|---|---|---|---|
| 1 | Account / Admin Settings | Super admin count, 2SV enrollment & enforcement | Admin account hygiene, directory sharing |
| 3.1.1 | Calendar | — | 6 controls |
| 3.1.2 | Drive & Docs | — | 13 controls |
| 3.1.3 | Gmail | SPF, DKIM, DMARC (DNS); IMAP/POP per-user; auto-forwarding per-user | Attachment safety, link protection, spoofing, TLS, compliance |
| 3.1.4 | Google Chat | — | File sharing, external access, webhooks |
| 3.1.6 | Groups for Business | Group external visibility & membership access | — |
The scanner authenticates using a Google Service Account with Domain-Wide Delegation (DWD) enabled. A Super Administrator must complete the one-time setup below.
- Go to the Google Cloud Console: https://console.cloud.google.com
- Select or create a project for the scanner.
- Navigate to IAM & Admin → Service Accounts.
- Click Create Service Account.
- Fill in the form:
- Name:
accuknox-sspm(or any descriptive name) - Description:
AccuKnox SSPM scanner
- Name:
- Click Create and Continue, skip optional role grants, click Done.
- Click the new service account, go to the Keys tab.
- Click Add Key → Create new key → JSON, then click Create.
- Save the downloaded
.jsonkey file securely — this is your--service-account-file.
- In the Google Cloud Console, open the service account.
- Click Edit (pencil icon).
- Expand Advanced settings and tick Enable Google Workspace Domain-wide Delegation.
- Click Save.
- Note the Client ID shown on the service account overview (a long numeric string) — you will need it in Step 3.
In the Google Cloud Console for your project (https://console.cloud.google.com/apis/library), enable both:
| API | Used for |
|---|---|
Admin SDK API (admin.googleapis.com) |
Directory (users, domains, OUs) and Reports |
Google Workspace Alert Center API (alertcenter.googleapis.com) |
Alert rules (Section 6 checks) |
Navigate to APIs & Services → Library, search for each, and click Enable.
-
Sign in to the Google Workspace Admin Console: https://admin.google.com
-
Navigate to Security → Access and data control → API controls → Manage Domain-wide Delegation.
-
Click Add new.
-
Enter the Client ID from Step 2 (the long numeric string).
-
In the OAuth Scopes field, paste the following scopes exactly (comma-separated, no spaces):
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.orgunit.readonly,https://www.googleapis.com/auth/admin.directory.group.readonly,https://www.googleapis.com/auth/admin.reports.audit.readonly,https://www.googleapis.com/auth/admin.reports.usage.readonly,https://www.googleapis.com/auth/apps.alerts,https://www.googleapis.com/auth/apps.groups.settings,https://www.googleapis.com/auth/gmail.settings.basicScope Purpose admin.directory.user.readonlyRead user accounts, admin status, 2SV enrollment admin.directory.domain.readonlyRead verified domains admin.directory.orgunit.readonlyRead organisational units admin.directory.group.readonlyRead all groups in the domain admin.reports.audit.readonlyRead Admin audit logs admin.reports.usage.readonlyRead usage reports apps.alertsRead Alert Center alert rules (Section 6) apps.groups.settingsRead group security settings (Section 3.1.6) gmail.settings.basicRead per-user IMAP, POP, forwarding settings (Section 3.1.3) -
Click Authorise.
Find your primary domain:
- Google Workspace Admin Console → Account → Domains → Manage domains.
It typically looks like example.com. This is your --domain.
Only verified domains are scanned for DNS checks (SPF, DKIM, DMARC).
| Value | Where to find it | CLI flag / env var |
|---|---|---|
| Service Account JSON | Downloaded in Step 1 | --service-account-file / SSPM_GWS_SA_FILE |
| Admin Email | Super Admin account email | --admin-email / SSPM_GWS_ADMIN_EMAIL |
| Domain | Primary domain from Step 5 | --domain / SSPM_GWS_DOMAIN |
- Read-only scopes only: the scopes listed above are all read-only.
Do not add any
.readonly-less or write scopes. - Dedicated project: use a separate GCP project for the scanner to isolate the service account from production workloads.
- Restrict key access: store the JSON key file with
chmod 600and never commit it to version control. - Rotate keys periodically: delete and recreate the JSON key annually or after any suspected exposure.
- Audit service account usage: review Admin SDK audit logs for unexpected access by the service account.
pip install -e ".[dev]" # development install with test dependencies
# or
pip install accuknox-sspm # production installRequirements: Python 3.11+
# Scan an MS365 tenant
sspm scan ms365 \
--tenant-id <TENANT_ID> \
--client-id <CLIENT_ID> \
--client-secret <SECRET> \
--tenant-domain contoso.onmicrosoft.com \
--output contoso-report.sarif.json \
--verbose
# Scan a Google Workspace domain
sspm scan gws \
--service-account-file /path/to/sa-key.json \
--admin-email admin@example.com \
--domain example.com \
--output gws-report.sarif.json \
--verbose
# Filter to a specific CIS profile
sspm scan ms365 ... --profile "E3 Level 1"
sspm scan gws ... --profile "Enterprise Level 1"
# Run specific rules only
sspm scan ms365 ... \
--rule ms365-cis-5.2.2.1 \
--rule ms365-cis-5.2.2.2
# List all registered rules
sspm rules list
# Summarise an existing report
sspm report summary contoso-report.sarif.jsonCredentials can also be provided via environment variables:
# MS365
export SSPM_TENANT_ID=<TENANT_ID>
export SSPM_CLIENT_ID=<CLIENT_ID>
export SSPM_CLIENT_SECRET=<SECRET>
sspm scan ms365 --tenant-domain contoso.onmicrosoft.com
# GWS
export SSPM_GWS_SA_FILE=/path/to/sa-key.json
export SSPM_GWS_ADMIN_EMAIL=admin@example.com
export SSPM_GWS_DOMAIN=example.com
sspm scan gwsimport asyncio
from sspm.core.engine import ScanEngine
from sspm.core.reporter import write_sarif
from sspm.providers.ms365.provider import MS365Provider
provider = MS365Provider(
tenant_id="...",
client_id="...",
client_secret="...",
tenant_domain="contoso.onmicrosoft.com",
)
engine = ScanEngine(provider=provider, profile_filter="E3 Level 1")
result = asyncio.run(engine.scan())
print(result.summary())
# {'total': 11, 'passed': 3, 'failed': 5, 'manual': 2, 'errors': 0, 'skipped': 1}
write_sarif(result, "contoso-report.sarif.json")The scanner produces a SARIF 2.1.0 document:
{
"$schema": "https://raw.githubusercontent.com/.../sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "AccuKnox SSPM",
"organization": "AccuKnox",
"rules": [ /* one reportingDescriptor per rule */ ]
}
},
"results": [
{
"ruleId": "ms365-cis-5.2.2.1",
"kind": "fail",
"level": "error",
"message": { "text": "No CA policy requires MFA for admin roles." },
"locations": [{ "logicalLocations": [{ "name": "contoso.onmicrosoft.com" }] }],
"relatedLocations": [ /* evidence */ ]
},
{
"ruleId": "ms365-cis-1.1.2",
"kind": "open",
"level": "none",
"message": { "text": "Manual verification required…" }
}
]
}]
}SARIF kind mapping:
| Finding Status | SARIF kind |
SARIF level |
|---|---|---|
| PASS | pass |
none |
| FAIL (critical/high) | fail |
error |
| FAIL (medium) | fail |
warning |
| FAIL (low) | fail |
note |
| MANUAL | open |
none |
| ERROR | review |
note |
| SKIPPED | none |
none |
- Create a new file under
sspm/providers/<provider>/rules/section<N>_<name>/cis_<x>_<y>_<z>.py - Define a class inheriting
MS365RuleorGWSRule - Set
metadata = RuleMetadata(...)with all CIS fields - Implement
async def check(self, data: CollectedData) -> Finding - Decorate with
@registry.rule
The rule is auto-discovered at runtime — no manual registration needed.
from sspm.core.registry import registry
from sspm.providers.gws.rules.base import GWSRule # or MS365Rule
@registry.rule
class CIS_X_Y_Z(GWSRule):
metadata = RuleMetadata(
id="gws-cis-X.Y.Z",
...
)
async def check(self, data: CollectedData):
value = data.get("some_data_key")
if value is None:
return self._skip("Data not available.")
if value == expected:
return self._pass("Control is compliant.")
return self._fail("Control is non-compliant.", evidence=[...])- Create
sspm/providers/<provider>/provider.pysubclassingBaseProvider - Implement
collect()to return aCollectedDatasnapshot - Create rules subclassing
BaseRulewithprovider = "<provider>" - Call
registry.autodiscover("sspm.providers.<provider>.rules")
pytest tests/ -vCopyright © 2026 AccuKnox. All rights reserved.
Proprietary and confidential.