Analytic rule catalog
Delinea - Sensitive secret accessed by new user first access in 14 days
Back
| Id | a7b167e2-9582-440b-8d47-53df2f73c4f4 |
| Rulename | Delinea - Sensitive secret accessed by new user (first access in 14 days) |
| Description | Detects when a user accesses a high-value secret for the first time, with no access to that same secret by that user in the prior 14 days. First-time access to a sensitive secret can indicate lateral movement or credential targeting. Delinea reports no in-band sensitivity flag, so a secret is treated as high-value when its name or its folder matches ‘sensitivePattern’ (common privileged naming conventions), or when it appears in ‘customSecretNames’ / ‘customFolders’. Both knobs are meant to be edited at deployment time - the pattern for substring matching, the lists for exact tenant-specific names. The folder is read from the notes document, since the platform leaves the top-level target.containerName empty on secret events. |
| Severity | High |
| Tactics | InitialAccess LateralMovement CredentialAccess |
| Techniques | T1078 T1555 |
| Required data connectors | DelineaPlatformConnector |
| Kind | Scheduled |
| Query frequency | 1h |
| Query period | 14d |
| Trigger threshold | 0 |
| Trigger operator | gt |
| Source Uri | https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Delinea%20Platform/Analytic%20Rules/DelineaSensitiveSecretNewUser.yaml |
| Version | 1.3.0 |
| Arm template | a7b167e2-9582-440b-8d47-53df2f73c4f4.json |
let detectionWindow = 1h; // must equal queryFrequency
let baseline = 14d; // must stay <= queryPeriod
// Delinea exposes no in-band sensitivity flag - tags and analyticData are null on every
// secret event - so value is inferred from the secret name and its folder. Secret Server
// permissions are inherited from folder placement, which makes containerName the strongest
// tiering signal, and it exists only inside notes: top-level target.containerName is null.
//
// TUNING 1 of 2: common high-value naming conventions, matched as a case insensitive
// substring against both the secret name and its folder. Trim groups that are noisy in
// your tenant; add cloud and CI terms (azure, aws, gcp, jenkins, ansible, terraform) only
// if those secrets are genuinely privileged, as they match broadly.
let sensitivePattern = @"(?i)admin|administrator|\broot\b|sudo|superuser|breakglass|break.?glass|emergency|privileged|\bpriv\b|\bda\b|\bea\b|domain.?admin|enterprise.?admin|\bsa[-_]|\bsvc[-_]|service.?account|\bprod\b|production|\bprd\b|backup|restore|disaster|\bdr\b|domain.?controller|\bdc\d|firewall|router|esxi|vcenter|hypervisor|\bsql\b|mysql|mssql|postgres|oracle|mongo|\bdba\b|\bkey\b|api.?key|keystore|vault|token|credential|password|passwd|\bpwd\b|cert\b|certificate|\bpki\b|ssh|rdp|winrm";
// TUNING 2 of 2: tenant specific names, filled in at deployment time. Exact match, case
// insensitive - use sensitivePattern above for substring matching. Leave empty if unused.
let customSecretNames = dynamic([]);
// e.g. dynamic(["Break Glass Account", "ESXi root", "SAP_PROD_ADMIN"]);
let customFolders = dynamic([]);
// e.g. dynamic(["Tier 0", "Domain Admins", "Production Databases"]);
let access =
DelineaAuditEvents_CL
| where TimeGenerated > ago(baseline)
| extend IngestedAt = ingestion_time()
| extend d = parse_json(RawData)
| where tostring(d.eventType.name) == "Delinea.Vault.Secret.Viewed"
// notes is a JSON document carried as a string - same idiom as
// Workbooks/DelineaSecretServerVaultActivity.json
| extend n = parse_json(tostring(d.notes))
| extend Actor = tostring(coalesce(d.actor.name, d.actor.email, d.actor.displayName)),
ActorEmail = tostring(d.actor.email),
SecretKey = tostring(coalesce(d.target.id, d.target.platformId, n.itemId)),
// Trimmed because these become entity identifiers - Delinea returns names
// such as '10.60.25.30 MYSQL ' with trailing whitespace.
SecretName = trim(@'\s', coalesce(tostring(d.target.displayName), tostring(d.target.name),
tostring(n.itemNameForDisplay), tostring(n.itemName), SecretId)),
Folder = trim(@'\s', tostring(n.containerName)),
Details = tostring(n.eventDetails),
RawSrcIP = tostring(coalesce(d.source.host.network.ipAddress, n.ipAddress))
// eventDetails is free text: " Account Name: qa-e2e-target Target Server: 10.60.25.51 "
| extend AccountName = extract(@'Account Name:\s*(.*?)\s*(?:Target Server:|$)', 1, Details),
TargetServer = extract(@'Target Server:\s*(.*?)\s*$', 1, Details)
| extend SrcIP = iff(RawSrcIP in ("", "0.0.0.0"), "", RawSrcIP)
| where isnotempty(Actor) and isnotempty(SecretKey)
// To rely on the custom lists alone, delete the two 'matches regex' clauses - do not
// blank sensitivePattern, an empty regex matches every row.
| where SecretName matches regex sensitivePattern
or Folder matches regex sensitivePattern
or SecretName in~ (customSecretNames)
or Folder in~ (customFolders);
// Batch by ingestion time so a late arriving event is still evaluated exactly once.
let priorAccess = access | where IngestedAt <= ago(detectionWindow) | distinct Actor, SecretKey;
access
| where IngestedAt > ago(detectionWindow)
| join kind=leftanti priorAccess on Actor, SecretKey
| summarize FirstAccess = min(TimeGenerated), LastAccess = max(TimeGenerated), Accesses = count(),
SecretName = take_any(SecretName), Folder = take_any(Folder),
ActorEmail = take_anyif(ActorEmail, isnotempty(ActorEmail)),
AccountNames = make_set_if(AccountName, isnotempty(AccountName), 10),
TargetServers = make_set_if(TargetServer, isnotempty(TargetServer), 10),
TargetServer = take_anyif(TargetServer, isnotempty(TargetServer)),
SourceIPs = make_set_if(SrcIP, isnotempty(SrcIP), 20),
SrcIP = take_anyif(SrcIP, isnotempty(SrcIP))
by Actor, SecretKey
// 'Target Server' is reported as either an address or a hostname, so each row feeds
// whichever entity fits and blanks the other. Sentinel skips empty entity values.
| extend TargetIsIp = isnotnull(parse_ipv4(TargetServer)) or isnotnull(parse_ipv6(TargetServer))
| extend TargetIP = iff(TargetIsIp, TargetServer, ""),
TargetHost = iff(TargetIsIp, "", TargetServer)
| project FirstAccess, LastAccess, Actor, ActorEmail, SecretKey, SecretName, Folder,
AccountNames, TargetServers, TargetHost, TargetIP, Accesses, SourceIPs, SrcIP
| sort by FirstAccess desc
incidentConfiguration:
groupingConfiguration:
lookbackDuration: P1D
reopenClosedIncident: false
groupByEntities:
- Account
matchingMethod: Selected
enabled: true
createIncident: true
triggerOperator: gt
OriginalUri: https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Delinea%20Platform/Analytic%20Rules/DelineaSensitiveSecretNewUser.yaml
triggerThreshold: 0
id: a7b167e2-9582-440b-8d47-53df2f73c4f4
kind: Scheduled
relevantTechniques:
- T1078
- T1555
version: 1.3.0
tactics:
- InitialAccess
- LateralMovement
- CredentialAccess
queryPeriod: 14d
queryFrequency: 1h
severity: High
name: Delinea - Sensitive secret accessed by new user (first access in 14 days)
customDetails:
SecretKey: SecretKey
Accesses: Accesses
AccountNames: AccountNames
Folder: Folder
SecretName: SecretName
TargetServers: TargetServers
requiredDataConnectors:
- connectorId: DelineaPlatformConnector
dataTypes:
- DelineaAuditEvents_CL
entityMappings:
- entityType: Account
fieldMappings:
- identifier: Name
columnName: Actor
- identifier: FullName
columnName: ActorEmail
- entityType: File
fieldMappings:
- identifier: Name
columnName: SecretName
- identifier: Directory
columnName: Folder
- entityType: IP
fieldMappings:
- identifier: Address
columnName: SrcIP
- entityType: Host
fieldMappings:
- identifier: HostName
columnName: TargetHost
- entityType: IP
fieldMappings:
- identifier: Address
columnName: TargetIP
description: |
Detects when a user accesses a high-value secret for the first time, with no access to that
same secret by that user in the prior 14 days. First-time access to a sensitive secret can
indicate lateral movement or credential targeting.
Delinea reports no in-band sensitivity flag, so a secret is treated as high-value when its
name or its folder matches 'sensitivePattern' (common privileged naming conventions), or
when it appears in 'customSecretNames' / 'customFolders'. Both knobs are meant to be edited
at deployment time - the pattern for substring matching, the lists for exact tenant-specific
names. The folder is read from the notes document, since the platform leaves the top-level
target.containerName empty on secret events.
eventGroupingSettings:
aggregationKind: AlertPerResult
status: Available
query: |
let detectionWindow = 1h; // must equal queryFrequency
let baseline = 14d; // must stay <= queryPeriod
// Delinea exposes no in-band sensitivity flag - tags and analyticData are null on every
// secret event - so value is inferred from the secret name and its folder. Secret Server
// permissions are inherited from folder placement, which makes containerName the strongest
// tiering signal, and it exists only inside notes: top-level target.containerName is null.
//
// TUNING 1 of 2: common high-value naming conventions, matched as a case insensitive
// substring against both the secret name and its folder. Trim groups that are noisy in
// your tenant; add cloud and CI terms (azure, aws, gcp, jenkins, ansible, terraform) only
// if those secrets are genuinely privileged, as they match broadly.
let sensitivePattern = @"(?i)admin|administrator|\broot\b|sudo|superuser|breakglass|break.?glass|emergency|privileged|\bpriv\b|\bda\b|\bea\b|domain.?admin|enterprise.?admin|\bsa[-_]|\bsvc[-_]|service.?account|\bprod\b|production|\bprd\b|backup|restore|disaster|\bdr\b|domain.?controller|\bdc\d|firewall|router|esxi|vcenter|hypervisor|\bsql\b|mysql|mssql|postgres|oracle|mongo|\bdba\b|\bkey\b|api.?key|keystore|vault|token|credential|password|passwd|\bpwd\b|cert\b|certificate|\bpki\b|ssh|rdp|winrm";
// TUNING 2 of 2: tenant specific names, filled in at deployment time. Exact match, case
// insensitive - use sensitivePattern above for substring matching. Leave empty if unused.
let customSecretNames = dynamic([]);
// e.g. dynamic(["Break Glass Account", "ESXi root", "SAP_PROD_ADMIN"]);
let customFolders = dynamic([]);
// e.g. dynamic(["Tier 0", "Domain Admins", "Production Databases"]);
let access =
DelineaAuditEvents_CL
| where TimeGenerated > ago(baseline)
| extend IngestedAt = ingestion_time()
| extend d = parse_json(RawData)
| where tostring(d.eventType.name) == "Delinea.Vault.Secret.Viewed"
// notes is a JSON document carried as a string - same idiom as
// Workbooks/DelineaSecretServerVaultActivity.json
| extend n = parse_json(tostring(d.notes))
| extend Actor = tostring(coalesce(d.actor.name, d.actor.email, d.actor.displayName)),
ActorEmail = tostring(d.actor.email),
SecretKey = tostring(coalesce(d.target.id, d.target.platformId, n.itemId)),
// Trimmed because these become entity identifiers - Delinea returns names
// such as '10.60.25.30 MYSQL ' with trailing whitespace.
SecretName = trim(@'\s', coalesce(tostring(d.target.displayName), tostring(d.target.name),
tostring(n.itemNameForDisplay), tostring(n.itemName), SecretId)),
Folder = trim(@'\s', tostring(n.containerName)),
Details = tostring(n.eventDetails),
RawSrcIP = tostring(coalesce(d.source.host.network.ipAddress, n.ipAddress))
// eventDetails is free text: " Account Name: qa-e2e-target Target Server: 10.60.25.51 "
| extend AccountName = extract(@'Account Name:\s*(.*?)\s*(?:Target Server:|$)', 1, Details),
TargetServer = extract(@'Target Server:\s*(.*?)\s*$', 1, Details)
| extend SrcIP = iff(RawSrcIP in ("", "0.0.0.0"), "", RawSrcIP)
| where isnotempty(Actor) and isnotempty(SecretKey)
// To rely on the custom lists alone, delete the two 'matches regex' clauses - do not
// blank sensitivePattern, an empty regex matches every row.
| where SecretName matches regex sensitivePattern
or Folder matches regex sensitivePattern
or SecretName in~ (customSecretNames)
or Folder in~ (customFolders);
// Batch by ingestion time so a late arriving event is still evaluated exactly once.
let priorAccess = access | where IngestedAt <= ago(detectionWindow) | distinct Actor, SecretKey;
access
| where IngestedAt > ago(detectionWindow)
| join kind=leftanti priorAccess on Actor, SecretKey
| summarize FirstAccess = min(TimeGenerated), LastAccess = max(TimeGenerated), Accesses = count(),
SecretName = take_any(SecretName), Folder = take_any(Folder),
ActorEmail = take_anyif(ActorEmail, isnotempty(ActorEmail)),
AccountNames = make_set_if(AccountName, isnotempty(AccountName), 10),
TargetServers = make_set_if(TargetServer, isnotempty(TargetServer), 10),
TargetServer = take_anyif(TargetServer, isnotempty(TargetServer)),
SourceIPs = make_set_if(SrcIP, isnotempty(SrcIP), 20),
SrcIP = take_anyif(SrcIP, isnotempty(SrcIP))
by Actor, SecretKey
// 'Target Server' is reported as either an address or a hostname, so each row feeds
// whichever entity fits and blanks the other. Sentinel skips empty entity values.
| extend TargetIsIp = isnotnull(parse_ipv4(TargetServer)) or isnotnull(parse_ipv6(TargetServer))
| extend TargetIP = iff(TargetIsIp, TargetServer, ""),
TargetHost = iff(TargetIsIp, "", TargetServer)
| project FirstAccess, LastAccess, Actor, ActorEmail, SecretKey, SecretName, Folder,
AccountNames, TargetServers, TargetHost, TargetIP, Accesses, SourceIPs, SrcIP
| sort by FirstAccess desc
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"workspace": {
"type": "String"
}
},
"resources": [
{
"apiVersion": "2024-01-01-preview",
"id": "[concat(resourceId('Microsoft.OperationalInsights/workspaces/providers', parameters('workspace'), 'Microsoft.SecurityInsights'),'/alertRules/a7b167e2-9582-440b-8d47-53df2f73c4f4')]",
"kind": "Scheduled",
"name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/a7b167e2-9582-440b-8d47-53df2f73c4f4')]",
"properties": {
"alertRuleTemplateName": "a7b167e2-9582-440b-8d47-53df2f73c4f4",
"customDetails": {
"Accesses": "Accesses",
"AccountNames": "AccountNames",
"Folder": "Folder",
"SecretKey": "SecretKey",
"SecretName": "SecretName",
"TargetServers": "TargetServers"
},
"description": "Detects when a user accesses a high-value secret for the first time, with no access to that\nsame secret by that user in the prior 14 days. First-time access to a sensitive secret can\nindicate lateral movement or credential targeting.\nDelinea reports no in-band sensitivity flag, so a secret is treated as high-value when its\nname or its folder matches 'sensitivePattern' (common privileged naming conventions), or\nwhen it appears in 'customSecretNames' / 'customFolders'. Both knobs are meant to be edited\nat deployment time - the pattern for substring matching, the lists for exact tenant-specific\nnames. The folder is read from the notes document, since the platform leaves the top-level\ntarget.containerName empty on secret events.\n",
"displayName": "Delinea - Sensitive secret accessed by new user (first access in 14 days)",
"enabled": true,
"entityMappings": [
{
"entityType": "Account",
"fieldMappings": [
{
"columnName": "Actor",
"identifier": "Name"
},
{
"columnName": "ActorEmail",
"identifier": "FullName"
}
]
},
{
"entityType": "File",
"fieldMappings": [
{
"columnName": "SecretName",
"identifier": "Name"
},
{
"columnName": "Folder",
"identifier": "Directory"
}
]
},
{
"entityType": "IP",
"fieldMappings": [
{
"columnName": "SrcIP",
"identifier": "Address"
}
]
},
{
"entityType": "Host",
"fieldMappings": [
{
"columnName": "TargetHost",
"identifier": "HostName"
}
]
},
{
"entityType": "IP",
"fieldMappings": [
{
"columnName": "TargetIP",
"identifier": "Address"
}
]
}
],
"eventGroupingSettings": {
"aggregationKind": "AlertPerResult"
},
"incidentConfiguration": {
"createIncident": true,
"groupingConfiguration": {
"enabled": true,
"groupByEntities": [
"Account"
],
"lookbackDuration": "P1D",
"matchingMethod": "Selected",
"reopenClosedIncident": false
}
},
"OriginalUri": "https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Delinea%20Platform/Analytic%20Rules/DelineaSensitiveSecretNewUser.yaml",
"query": "let detectionWindow = 1h; // must equal queryFrequency\nlet baseline = 14d; // must stay <= queryPeriod\n// Delinea exposes no in-band sensitivity flag - tags and analyticData are null on every\n// secret event - so value is inferred from the secret name and its folder. Secret Server\n// permissions are inherited from folder placement, which makes containerName the strongest\n// tiering signal, and it exists only inside notes: top-level target.containerName is null.\n//\n// TUNING 1 of 2: common high-value naming conventions, matched as a case insensitive\n// substring against both the secret name and its folder. Trim groups that are noisy in\n// your tenant; add cloud and CI terms (azure, aws, gcp, jenkins, ansible, terraform) only\n// if those secrets are genuinely privileged, as they match broadly.\nlet sensitivePattern = @\"(?i)admin|administrator|\\broot\\b|sudo|superuser|breakglass|break.?glass|emergency|privileged|\\bpriv\\b|\\bda\\b|\\bea\\b|domain.?admin|enterprise.?admin|\\bsa[-_]|\\bsvc[-_]|service.?account|\\bprod\\b|production|\\bprd\\b|backup|restore|disaster|\\bdr\\b|domain.?controller|\\bdc\\d|firewall|router|esxi|vcenter|hypervisor|\\bsql\\b|mysql|mssql|postgres|oracle|mongo|\\bdba\\b|\\bkey\\b|api.?key|keystore|vault|token|credential|password|passwd|\\bpwd\\b|cert\\b|certificate|\\bpki\\b|ssh|rdp|winrm\";\n// TUNING 2 of 2: tenant specific names, filled in at deployment time. Exact match, case\n// insensitive - use sensitivePattern above for substring matching. Leave empty if unused.\nlet customSecretNames = dynamic([]);\n// e.g. dynamic([\"Break Glass Account\", \"ESXi root\", \"SAP_PROD_ADMIN\"]);\nlet customFolders = dynamic([]);\n// e.g. dynamic([\"Tier 0\", \"Domain Admins\", \"Production Databases\"]);\nlet access =\n DelineaAuditEvents_CL\n | where TimeGenerated > ago(baseline)\n | extend IngestedAt = ingestion_time()\n | extend d = parse_json(RawData)\n | where tostring(d.eventType.name) == \"Delinea.Vault.Secret.Viewed\"\n // notes is a JSON document carried as a string - same idiom as\n // Workbooks/DelineaSecretServerVaultActivity.json\n | extend n = parse_json(tostring(d.notes))\n | extend Actor = tostring(coalesce(d.actor.name, d.actor.email, d.actor.displayName)),\n ActorEmail = tostring(d.actor.email),\n SecretKey = tostring(coalesce(d.target.id, d.target.platformId, n.itemId)),\n // Trimmed because these become entity identifiers - Delinea returns names\n // such as '10.60.25.30 MYSQL ' with trailing whitespace.\n SecretName = trim(@'\\s', coalesce(tostring(d.target.displayName), tostring(d.target.name),\n tostring(n.itemNameForDisplay), tostring(n.itemName), SecretId)),\n Folder = trim(@'\\s', tostring(n.containerName)),\n Details = tostring(n.eventDetails),\n RawSrcIP = tostring(coalesce(d.source.host.network.ipAddress, n.ipAddress))\n // eventDetails is free text: \" Account Name: qa-e2e-target Target Server: 10.60.25.51 \"\n | extend AccountName = extract(@'Account Name:\\s*(.*?)\\s*(?:Target Server:|$)', 1, Details),\n TargetServer = extract(@'Target Server:\\s*(.*?)\\s*$', 1, Details)\n | extend SrcIP = iff(RawSrcIP in (\"\", \"0.0.0.0\"), \"\", RawSrcIP)\n | where isnotempty(Actor) and isnotempty(SecretKey)\n // To rely on the custom lists alone, delete the two 'matches regex' clauses - do not\n // blank sensitivePattern, an empty regex matches every row.\n | where SecretName matches regex sensitivePattern\n or Folder matches regex sensitivePattern\n or SecretName in~ (customSecretNames)\n or Folder in~ (customFolders);\n// Batch by ingestion time so a late arriving event is still evaluated exactly once.\nlet priorAccess = access | where IngestedAt <= ago(detectionWindow) | distinct Actor, SecretKey;\naccess\n| where IngestedAt > ago(detectionWindow)\n| join kind=leftanti priorAccess on Actor, SecretKey\n| summarize FirstAccess = min(TimeGenerated), LastAccess = max(TimeGenerated), Accesses = count(),\n SecretName = take_any(SecretName), Folder = take_any(Folder),\n ActorEmail = take_anyif(ActorEmail, isnotempty(ActorEmail)),\n AccountNames = make_set_if(AccountName, isnotempty(AccountName), 10),\n TargetServers = make_set_if(TargetServer, isnotempty(TargetServer), 10),\n TargetServer = take_anyif(TargetServer, isnotempty(TargetServer)),\n SourceIPs = make_set_if(SrcIP, isnotempty(SrcIP), 20),\n SrcIP = take_anyif(SrcIP, isnotempty(SrcIP))\n by Actor, SecretKey\n// 'Target Server' is reported as either an address or a hostname, so each row feeds\n// whichever entity fits and blanks the other. Sentinel skips empty entity values.\n| extend TargetIsIp = isnotnull(parse_ipv4(TargetServer)) or isnotnull(parse_ipv6(TargetServer))\n| extend TargetIP = iff(TargetIsIp, TargetServer, \"\"),\n TargetHost = iff(TargetIsIp, \"\", TargetServer)\n| project FirstAccess, LastAccess, Actor, ActorEmail, SecretKey, SecretName, Folder,\n AccountNames, TargetServers, TargetHost, TargetIP, Accesses, SourceIPs, SrcIP\n| sort by FirstAccess desc\n",
"queryFrequency": "PT1H",
"queryPeriod": "P14D",
"severity": "High",
"status": "Available",
"subTechniques": [],
"suppressionDuration": "PT1H",
"suppressionEnabled": false,
"tactics": [
"CredentialAccess",
"InitialAccess",
"LateralMovement"
],
"techniques": [
"T1078",
"T1555"
],
"templateVersion": "1.3.0",
"triggerOperator": "GreaterThan",
"triggerThreshold": 0
},
"type": "Microsoft.OperationalInsights/workspaces/providers/alertRules"
}
]
}