Skip to content

Commit 40299d2

Browse files
committed
initial commit
2 parents 5c13a34 + 480e41e commit 40299d2

2 files changed

Lines changed: 337 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Every enterprise has security-relevant log sources that no Microsoft- or partner-authored connector covers: bespoke line-of-business applications, internal authentication services, custom proxies, internally developed APIs, mainframe security exits, and homegrown SaaS. When these sources have no detection coverage, threat actor activity that touches them — credential reuse against an internal application, exploitation of a custom API, data staging from a homegrown SaaS prior to exfiltration — is silently absent from Sentinel's incident graph. Sentinel exposes three documented mechanisms for shipping these logs into the workspace and registering them as connectors: the Codeless Connector Framework (GenericUI / APIPolling kinds) for REST API sources, the Logs Ingestion API plus Data Collection Rules for arbitrary HTTP-shipped logs, and Logstash/Function App connectors. Building at least one custom connector is the documented best practice when an in-scope source has no existing connector — without it, the only alternative is to drop the source entirely (no detection) or run a parallel logging pipeline that bypasses the SIEM (no correlation). This check confirms that the Sentinel workspace contains at least one codeless connector whose publisher matches the customer's organization. Connectors published by another organization remain unresolved because the API does not distinguish partner-authored connectors from customer-authored connectors.
2+
3+
**Remediation action**
4+
5+
- [Create a codeless data connector for Microsoft Sentinel](https://learn.microsoft.com/azure/sentinel/create-codeless-connector)
6+
- [Send data to Microsoft Sentinel using the Logs ingestion API](https://learn.microsoft.com/azure/azure-monitor/logs/logs-ingestion-api-overview)
7+
- [Use Azure Functions to connect Microsoft Sentinel to your data source](https://learn.microsoft.com/azure/sentinel/connect-azure-functions-template)
8+
9+
<!--- Results --->
10+
%TestResult%
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
<#
2+
.SYNOPSIS
3+
Checks that custom data connectors are configured in Microsoft Sentinel for in-scope sources
4+
without a built-in connector.
5+
6+
.NOTES
7+
Test ID: 41203
8+
Workshop Task: SECOPS_095
9+
Pillar: SecOps
10+
Category: Security information and event management
11+
Required API: Azure Resource Manager (management.azure.com)
12+
#>
13+
function Test-Assessment-41203 {
14+
[ZtTest(
15+
Category = 'Security information and event management',
16+
ImplementationCost = 'High',
17+
MinimumLicense = ('Consumption-based: Microsoft Sentinel'),
18+
Pillar = 'SecOps',
19+
RiskLevel = 'Low',
20+
Service = ('Azure'),
21+
SfiPillar = 'Monitor and detect cyberthreats',
22+
TenantType = ('Workforce'),
23+
TestId = 41203,
24+
Title = 'Custom data connectors are configured in Microsoft Sentinel for in-scope sources without a built-in connector',
25+
UserImpact = 'Low'
26+
)]
27+
[CmdletBinding()]
28+
param()
29+
30+
$testTitle = 'Custom data connectors are configured in Microsoft Sentinel for in-scope sources without a built-in connector'
31+
32+
#region Data Collection
33+
34+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
35+
$activity = 'Checking custom data connectors in Microsoft Sentinel workspaces'
36+
$azContext = Get-AzContext -ErrorAction SilentlyContinue
37+
$tenantId = if ($azContext) { [string]$azContext.Tenant.Id } else { $null }
38+
$customerPublisher = if ($tenantId) { Get-ZtTenantName -TenantId $tenantId } else { $null }
39+
if ($customerPublisher -eq $tenantId) {
40+
$customerPublisher = $null
41+
}
42+
43+
# Q1 + Q2 + onboarding check via shared helper.
44+
# Returns 'Forbidden' on ARG 401/403 (Investigate).
45+
# Returns $null on unexpected ARG failure (Investigate).
46+
# Returns 'NoSubscriptions' when no enabled subscriptions are accessible (Skip).
47+
# Returns 'NoWorkspaces' when no Log Analytics workspaces exist in scope (Skip).
48+
$allWorkspaces = Get-SentinelWorkspaceData -Activity $activity
49+
50+
if ($null -eq $allWorkspaces) {
51+
$params = @{
52+
TestId = '41203'
53+
Title = $testTitle
54+
Status = $false
55+
Result = '⚠️ Azure Resource Graph returned an unexpected error while querying subscriptions or Log Analytics workspaces. This is likely a transient issue, please re-run the assessment.'
56+
CustomStatus = 'Investigate'
57+
}
58+
Add-ZtTestResultDetail @params
59+
return
60+
}
61+
62+
if ($allWorkspaces -eq 'Forbidden') {
63+
$params = @{
64+
TestId = '41203'
65+
Title = $testTitle
66+
Status = $false
67+
Result = '⚠️ Azure Resource Graph returned insufficient permissions when querying subscriptions or workspaces. Ensure you have at least Reader access to the Azure subscriptions being tested.'
68+
CustomStatus = 'Investigate'
69+
}
70+
Add-ZtTestResultDetail @params
71+
return
72+
}
73+
74+
if ($allWorkspaces -eq 'NoSubscriptions') {
75+
Write-PSFMessage 'No enabled subscriptions found — skipping Sentinel custom data connectors check.' -Tag Test -Level VeryVerbose
76+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
77+
return
78+
}
79+
80+
if ($allWorkspaces -eq 'NoWorkspaces') {
81+
Write-PSFMessage 'No Log Analytics workspaces found across accessible subscriptions — skipping Sentinel custom data connectors check.' -Tag Test -Level VeryVerbose
82+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
83+
return
84+
}
85+
86+
$checkableWorkspaces = @($allWorkspaces | Where-Object { -not $_.PermissionError })
87+
$forbiddenWorkspaces = @($allWorkspaces | Where-Object { $_.PermissionError })
88+
$onboardedWorkspaces = @($checkableWorkspaces | Where-Object { $_.SentinelOnboarded })
89+
# An onboarding check that failed unexpectedly is unresolved data, not a confirmed 'not onboarded'.
90+
$unresolvedWorkspaces = @($checkableWorkspaces | Where-Object { $_.OnboardingError })
91+
92+
if ($onboardedWorkspaces.Count -eq 0) {
93+
if ($forbiddenWorkspaces.Count -gt 0 -or $unresolvedWorkspaces.Count -gt 0) {
94+
# Auth errors mean we cannot confirm whether those workspaces have Sentinel onboarded;
95+
# a passing workspace may exist among the inaccessible ones.
96+
$params = @{
97+
TestId = '41203'
98+
Title = $testTitle
99+
Status = $false
100+
Result = '⚠️ One or more Log Analytics workspaces returned insufficient permissions or an unexpected error when checking Sentinel onboarding state. No Sentinel-onboarded workspace was confirmed among accessible workspaces — the overall state cannot be determined. Ensure Microsoft Sentinel Reader is granted on all workspaces and re-run the assessment.'
101+
CustomStatus = 'Investigate'
102+
}
103+
Add-ZtTestResultDetail @params
104+
}
105+
else {
106+
# Spec: no Sentinel-onboarded workspaces with full visibility — Skipped.
107+
Write-PSFMessage 'No Sentinel-onboarded workspaces found — skipping Sentinel custom data connectors check.' -Tag Test -Level VeryVerbose
108+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
109+
}
110+
return
111+
}
112+
113+
# Q1: data connectors, Q2: codeless connector definitions, per Sentinel-onboarded workspace.
114+
# Invoke-ZtAzureRequest paginates automatically (Paginate=$true for GET) and unwraps .value.
115+
$connectorsByWorkspace = @{}
116+
$definitionsByWorkspace = @{}
117+
118+
foreach ($workspace in $onboardedWorkspaces) {
119+
Write-ZtProgress -Activity $activity -Status "Fetching data connectors for workspace '$($workspace.WorkspaceName)' in '$($workspace.SubscriptionName)'"
120+
121+
try {
122+
# api-version 2024-09-01 rejects the GenericUI/APIPolling kinds; only the preview version returns them.
123+
$connectorsByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/dataConnectors?api-version=2021-03-01-preview" -ErrorAction Stop)
124+
}
125+
catch {
126+
$connectorsByWorkspace[$workspace.WorkspaceId] = $null
127+
Write-PSFMessage "Error querying data connectors for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
128+
}
129+
130+
try {
131+
$definitionsByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/dataConnectorDefinitions?api-version=2024-09-01" -ErrorAction Stop)
132+
}
133+
catch {
134+
$definitionsByWorkspace[$workspace.WorkspaceId] = $null
135+
Write-PSFMessage "Error querying data connector definitions for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
136+
}
137+
}
138+
139+
#endregion Data Collection
140+
141+
#region Assessment Logic
142+
143+
# Codeless / custom-builder connector kinds per KnownDataConnectorKind.
144+
$codelessKinds = @('GenericUI', 'APIPolling')
145+
# A non-Microsoft publisher is not sufficient evidence of customer authorship because it may be a partner.
146+
$builtInPublishers = @('Microsoft', 'Microsoft Corporation')
147+
148+
$workspaceResults = foreach ($workspace in $onboardedWorkspaces) {
149+
$rawConnectors = $connectorsByWorkspace[$workspace.WorkspaceId]
150+
$rawDefinitions = $definitionsByWorkspace[$workspace.WorkspaceId]
151+
152+
$codelessConnectors = @()
153+
$customConnectors = @()
154+
$customDefinitions = @()
155+
$rowStatus = 'Fail'
156+
157+
if ($null -eq $rawConnectors) {
158+
# API error for this workspace — cannot determine connector state.
159+
$rowStatus = 'Investigate'
160+
}
161+
else {
162+
# Definition publishers let a connector that omits connectorUiConfig.publisher still be classified.
163+
$definitionPublishers = @{}
164+
foreach ($definition in $rawDefinitions) {
165+
$definitionPublishers[$definition.name] = $definition.properties.connectorUiConfig.publisher
166+
}
167+
168+
$customDefinitions = @($rawDefinitions | Where-Object {
169+
$customerPublisher -and $_.properties.connectorUiConfig.publisher -eq $customerPublisher
170+
})
171+
172+
$codelessConnectors = foreach ($connector in @($rawConnectors | Where-Object { $codelessKinds -contains $_.kind })) {
173+
$publisher = $connector.properties.connectorUiConfig.publisher
174+
$definitionName = $connector.properties.connectorDefinitionName
175+
if ([string]::IsNullOrWhiteSpace($publisher) -and $definitionName) {
176+
$publisher = $definitionPublishers[$definitionName]
177+
}
178+
179+
[PSCustomObject]@{
180+
Title = if ($connector.properties.connectorUiConfig.title) { $connector.properties.connectorUiConfig.title } else { $connector.name }
181+
Publisher = $publisher
182+
DefinitionName = $definitionName
183+
IsCustom = $customerPublisher -and $publisher -eq $customerPublisher
184+
IsUnresolved = [string]::IsNullOrWhiteSpace($publisher) -or ($builtInPublishers -notcontains $publisher -and $publisher -ne $customerPublisher)
185+
}
186+
}
187+
$codelessConnectors = @($codelessConnectors)
188+
$customConnectors = @($codelessConnectors | Where-Object IsCustom)
189+
190+
$rowStatus = if ($null -eq $rawDefinitions) {
191+
# Q2 errors make the workspace unresolved even when Q1 contains an apparent custom connector.
192+
'Investigate'
193+
}
194+
elseif ($customConnectors.Count -ge 1) {
195+
'Pass'
196+
}
197+
elseif (@($codelessConnectors | Where-Object IsUnresolved).Count -gt 0) {
198+
# Missing or third-party publisher data cannot establish customer authorship.
199+
'Investigate'
200+
}
201+
else {
202+
'Fail'
203+
}
204+
}
205+
206+
[PSCustomObject]@{
207+
SubscriptionName = $workspace.SubscriptionName
208+
SubscriptionId = $workspace.SubscriptionId
209+
WorkspaceName = $workspace.WorkspaceName
210+
ResourceGroup = $workspace.ResourceGroup
211+
WorkspaceId = $workspace.WorkspaceId
212+
CodelessConnectors = $codelessConnectors
213+
CustomConnectorCount = $customConnectors.Count
214+
CustomDefinitionCount = if ($null -eq $rawDefinitions) { $null } else { $customDefinitions.Count }
215+
RowStatus = $rowStatus
216+
}
217+
}
218+
$workspaceResults = @($workspaceResults)
219+
220+
$unresolvedWorkspaceResults = foreach ($workspace in @($forbiddenWorkspaces) + @($unresolvedWorkspaces)) {
221+
[PSCustomObject]@{
222+
SubscriptionName = $workspace.SubscriptionName
223+
SubscriptionId = $workspace.SubscriptionId
224+
WorkspaceName = $workspace.WorkspaceName
225+
ResourceGroup = $workspace.ResourceGroup
226+
WorkspaceId = $workspace.WorkspaceId
227+
CodelessConnectors = @()
228+
CustomConnectorCount = 0
229+
CustomDefinitionCount = $null
230+
RowStatus = 'Investigate'
231+
}
232+
}
233+
$workspaceResults = @($workspaceResults) + @($unresolvedWorkspaceResults)
234+
235+
$passedItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Pass' })
236+
$investigateItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' })
237+
238+
$hasUnresolved = $investigateItems.Count -gt 0 -or $forbiddenWorkspaces.Count -gt 0 -or $unresolvedWorkspaces.Count -gt 0
239+
$passed = -not $hasUnresolved -and $passedItems.Count -gt 0
240+
$customStatus = $null
241+
242+
if ($hasUnresolved) {
243+
$customStatus = 'Investigate'
244+
$testResultMarkdown = "⚠️ The codeless connector inventory could not be classified as customer-authored versus partner-authored from the API response.`n`n%TestResult%"
245+
}
246+
elseif ($passed) {
247+
$testResultMarkdown = "✅ At least one custom data connector is configured in the Sentinel workspace.`n`n%TestResult%"
248+
}
249+
else {
250+
$testResultMarkdown = "❌ No custom data connectors are configured.`n`n%TestResult%"
251+
}
252+
253+
#endregion Assessment Logic
254+
255+
#region Report Generation
256+
257+
$portalHost = if ($azContext -and $azContext.Environment.Name -eq 'AzureUSGovernment') { 'https://portal.azure.us' } else { 'https://portal.azure.com' }
258+
$portalSentinelLink = "$portalHost/#view/HubsExtension/BrowseResource/resourceType/microsoft.securityinsightsarg%2Fsentinel"
259+
$tableTitle = 'Custom data connectors per Sentinel workspace'
260+
261+
$formatTemplate = @'
262+
263+
264+
## [{0}]({1})
265+
266+
| Subscription | Workspace | Codeless connectors | Publishers | Connector definitions | Custom definitions | Status |
267+
| :----------- | :-------- | :------------------ | :--------- | :-------------------- | :----------------- | :----- |
268+
{2}
269+
'@
270+
271+
$tableRows = ''
272+
$maxDisplay = 10
273+
$statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2 }
274+
$displayResults = @($workspaceResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName)
275+
$hasMoreItems = $false
276+
if ($workspaceResults.Count -gt $maxDisplay) {
277+
$displayResults = @($displayResults | Select-Object -First $maxDisplay)
278+
$hasMoreItems = $true
279+
}
280+
281+
foreach ($result in $displayResults) {
282+
$subLink = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)"
283+
$sentinelId = "/subscriptions/$($result.SubscriptionId)/resourcegroups/$($result.ResourceGroup)/providers/microsoft.securityinsightsarg/sentinel/$($result.WorkspaceName)"
284+
$connectorLink = "$portalHost/#view/Microsoft_Azure_Security_Insights/MainMenuBlade/~/DataConnectors/id/$($sentinelId -replace '/', '%2F')"
285+
$subMd = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subLink)"
286+
$workspaceMd = "[$(Get-SafeMarkdown $result.WorkspaceName)]($connectorLink)"
287+
$definitionsMd = if ($null -eq $result.CustomDefinitionCount) { '' } else { $result.CustomDefinitionCount }
288+
$statusDisplay = switch ($result.RowStatus) {
289+
'Pass' { '✅ Pass' }
290+
'Fail' { '❌ Fail' }
291+
'Investigate' { '⚠️ Investigate' }
292+
}
293+
294+
if ($result.CodelessConnectors.Count -gt 0) {
295+
$titlesMd = ($result.CodelessConnectors | ForEach-Object { Get-SafeMarkdown $_.Title }) -join ', '
296+
$publishersMd = ($result.CodelessConnectors | ForEach-Object { if ($_.Publisher) { Get-SafeMarkdown $_.Publisher } else { 'Unknown' } }) -join ', '
297+
$namesMd = ($result.CodelessConnectors | ForEach-Object { if ($_.DefinitionName) { Get-SafeMarkdown $_.DefinitionName } else { '' } }) -join ', '
298+
$tableRows += "| $subMd | $workspaceMd | $titlesMd | $publishersMd | $namesMd | $definitionsMd | $statusDisplay |`n"
299+
}
300+
else {
301+
# No codeless connectors (Fail) or API error (Investigate) — one placeholder row so the workspace appears in the table.
302+
$tableRows += "| $subMd | $workspaceMd | — | — | — | $definitionsMd | $statusDisplay |`n"
303+
}
304+
}
305+
306+
if ($hasMoreItems) {
307+
$remainingCount = $workspaceResults.Count - $maxDisplay
308+
$tableRows += "`n... and $remainingCount more. [View all in Microsoft Sentinel]($portalSentinelLink)`n"
309+
}
310+
311+
$mdInfo = $formatTemplate -f $tableTitle, $portalSentinelLink, $tableRows
312+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
313+
314+
#endregion Report Generation
315+
316+
$params = @{
317+
TestId = '41203'
318+
Title = $testTitle
319+
Status = $passed
320+
Result = $testResultMarkdown
321+
}
322+
if ($customStatus) {
323+
$params.CustomStatus = $customStatus
324+
}
325+
326+
Add-ZtTestResultDetail @params
327+
}

0 commit comments

Comments
 (0)