Kusto Query Language (KQL) Queries For SOC Investigation.

Introduction.

In modern Security Operations Centres (SOCs), the ability to rapidly query large volumes of telemetry data is critical to effective incident response and threat hunting. Microsoft Sentinel, underpinned by Azure Log Analytics, leverages the Kusto Query Language (KQL) — a powerful, intuitive syntax built for scalability and speed. This article outlines a selection of high-value KQL queries that every SOC analyst should be familiar with to detect suspicious behaviour, correlate incidents, and reduce mean time to resolution (MTTR).


1: Sign-In Activity.

1.1: Identify recent sign-ins for a specific user with ASN, IP, and device details over a 10 day period.

let targetUser = "FirstName.LastName";
let timeWindow = 10d;
SigninLogs
| where TimeGenerated >= ago(timeWindow)
| where tolower(UserPrincipalName) has tolower(targetUser)
| extend Device = DeviceDetail, Location = LocationDetails, CAPolicies = parse_json(ConditionalAccessPolicies)
| mv-expand CAPolicy = CAPolicies
| extend
    CA_DisplayName = tostring(CAPolicy.displayName),
    CA_Result = tostring(CAPolicy.result),
    CA_GrantControls = iif(array_length(CAPolicy.enforcedGrantControls) > 0, strcat_array(CAPolicy.enforcedGrantControls, ", "), "")
| summarize
    CA_Policy_Summary = make_set(strcat(CA_DisplayName, iif(CA_GrantControls != "", strcat(" (", CA_GrantControls, ")"), ""), " → ", CA_Result))
    by
    TimeGenerated,
    ResultType,
    IPAddress,
    ASN = tostring(AutonomousSystemNumber),
    DeviceID = tostring(Device.deviceId),
    DeviceName = tostring(Device.displayName),
    OS = tostring(Device.operatingSystem),
    Browser = tostring(Device.browser),
    TrustType = tostring(Device.trustType),
    App = AppDisplayName,
    ClientApp = ClientAppUsed,
    ConditionalAccess = tostring(ConditionalAccessStatus),
    MFA = tostring(MfaDetail),
    City = tostring(Location.city),
    State = tostring(Location.state),
    Country = tostring(Location.countryOrRegion),
    Latitude = tostring(Location.geoCoordinates.latitude),
    Longitude = tostring(Location.geoCoordinates.longitude),
    UserAgent
| project
    SigninTime = TimeGenerated,
    ResultType,
    IPAddress,
    ASN,
    DeviceID,
    DeviceName,
    OS,
    Browser,
    TrustType,
    App,
    ClientApp,
    ConditionalAccess,
    CA_Policy_Summary,
    MFA,
    City,
    State,
    Country,
    Latitude,
    Longitude,
    UserAgent
| sort by SigninTime desc
// NOTE:
// This query returns all interactive sign-ins for the specified user over the past 10 days.
// It includes IP address, ASN, device details, trust type, application used, Conditional Access results, and MFA status.
// Applied Conditional Access policies are listed with any enforced grant controls.
// This is useful for reviewing user sign-in behaviour and evaluating CA policy effectiveness.
// Limitations:
// It only includes interactive sign-ins. Device trust type or location may be incomplete for personal devices.
// Post-sign-in activity is not shown — use OfficeActivity or CloudAppEvents for that.
let BaseData = 
SigninLogs
| where TimeGenerated >= ago(30d)
| where UserDisplayName contains "FirstName LastName"
| extend
    LocationDetails = tostring(Location),
    App = tostring(AppDisplayName),
    Resource = tostring(ResourceDisplayName),
    ClientApp = tostring(ClientAppUsed),
    AuthRequirement = tostring(AuthenticationRequirement),
    AuthRequirementPolicies = tostring(AuthenticationRequirementPolicies),
    CAStatus = tostring(ConditionalAccessStatus),
    CAPoliciesRaw = tostring(ConditionalAccessPolicies),
    MFA = tostring(MfaDetail),
    DeviceID = tostring(DeviceDetail.DeviceId),
    DeviceName = tostring(DeviceDetail.DisplayName),
    OperatingSystem = tostring(DeviceDetail.OperatingSystem),
    Browser = tostring(DeviceDetail.Browser),
    IsCompliant = tostring(DeviceDetail.IsCompliant),
    IsManaged = tostring(DeviceDetail.IsManaged),
    IsAzureADJoined = tostring(DeviceDetail.IsAzureADJoined),
    IsHybridAzureADJoined = tostring(DeviceDetail.IsHybridAzureADJoined),
    UserAgent = tostring(UserAgent),
    ASN = tostring(AutonomousSystemNumber);
let CA_Policy_Summary = 
BaseData
| where isnotempty(CAPoliciesRaw)
| extend CAPolicyObjects = parse_json(CAPoliciesRaw)
| mv-apply policy = CAPolicyObjects on (
    where policy.result in~ ("success", "failure", "reportOnlyNotApplied")
    | extend CAPolicySummary = strcat(
        tostring(policy.displayName), " → ", tostring(policy.result),
        iif(array_length(policy.enforcedGrantControls) > 0,
            strcat(" (", strcat_array(policy.enforcedGrantControls, ", "), ")"),
            "")
    )
)
| summarize SampleCAPolicies = make_set(CAPolicySummary) by IPAddress, ASN, UserPrincipalName;
BaseData
| summarize
    SuccessfulLogins = countif(ResultType == 0),
    FailedLogins = countif(ResultType != 0),
    CA_Applied_Success = countif(CAStatus == "success"),
    CA_Applied_Failure = countif(CAStatus == "failure"),
    CA_NotApplied = countif(CAStatus == "notApplied"),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    SampleDevice = any(DeviceName),
    SampleOS = any(OperatingSystem),
    SampleBrowser = any(Browser),
    SampleADJoin = any(IsAzureADJoined),
    SampleHybridJoin = any(IsHybridAzureADJoined),
    SampleCompliant = any(IsCompliant),
    SampleManaged = any(IsManaged),
    SampleApp = any(App),
    SampleResource = any(Resource),
    SampleClientApp = any(ClientApp),
    SampleAuthRequirement = any(AuthRequirement),
    SampleAuthPolicies = any(AuthRequirementPolicies),
    SampleCAStatus = any(CAStatus),
    SampleMFA = any(MFA),
    SampleUserAgent = any(UserAgent),
    Location = any(LocationDetails),
    DeviceDetailObject = any(DeviceDetail)
  by IPAddress, ASN, UserPrincipalName
| join kind=leftouter CA_Policy_Summary on IPAddress, ASN, UserPrincipalName
| extend SampleCAPolicies = strcat_array(SampleCAPolicies, " | ")
| sort by LastSeen desc
| project
    IPAddress,
    ASN,
    UserPrincipalName,
    SuccessfulLogins,
    FailedLogins,
    CA_Applied_Success,
    CA_Applied_Failure,
    CA_NotApplied,
    FirstSeen,
    LastSeen,
    SampleDevice,
    SampleOS,
    SampleBrowser,
    SampleADJoin,
    SampleHybridJoin,
    SampleCompliant,
    SampleManaged,
    SampleApp,
    SampleResource,
    SampleClientApp,
    SampleAuthRequirement,
    SampleAuthPolicies,
    SampleCAStatus,
    SampleCAPolicies,
    SampleMFA,
    SampleUserAgent,
    Location,
    DeviceDetailObject
// NOTE: This query summarises all interactive sign-ins, even when no Conditional Access policies were evaluated.
// It separately extracts CA policy details where present and joins them back in, preserving full coverage of all sign-in IPs.
// Ideal for user behavioural summaries, not intended for incident forensics or conditional access policy effectiveness analysis without validation.

1.3: Summarise non-interactive (AADNonInteractiveUserSignInLogs) sign-ins for a specific user with ASN, IP, device details and Conditional Access (CA) over a 30-day period:

AADNonInteractiveUserSignInLogs
| where TimeGenerated >= ago(30d)
| where UserPrincipalName contains "FirstName.LastName"
| extend DeviceDetailParsed = parse_json(DeviceDetail)
| extend
    DeviceID = tostring(DeviceDetailParsed["deviceId"]),
    DeviceName = tostring(DeviceDetailParsed["displayName"]),
    OperatingSystem = tostring(DeviceDetailParsed["operatingSystem"]),
    Browser = tostring(DeviceDetailParsed["browser"]),
    TrustType = tostring(DeviceDetailParsed["trustType"]),
    IsCompliant = tostring(DeviceDetailParsed["isCompliant"]),
    IsManaged = tostring(DeviceDetailParsed["isManaged"]),
    App = tostring(AppDisplayName),
    Resource = tostring(ResourceDisplayName),
    ClientApp = tostring(ClientAppUsed),
    AuthRequirement = tostring(AuthenticationRequirement),
    AuthRequirementPolicies = tostring(AuthenticationRequirementPolicies),
    CAStatus = tostring(ConditionalAccessStatus),
    CAPolicies = tostring(ConditionalAccessPolicies),
    MFA = tostring(MfaDetail),
    UserAgent = tostring(UserAgent),
    ASN = tostring(AutonomousSystemNumber)
| summarize
    SuccessfulLogins = countif(ResultType == 0),
    FailedLogins = countif(ResultType != 0),
    CA_Applied_Success = countif(CAStatus == "success"),
    CA_Applied_Failure = countif(CAStatus == "failure"),
    CA_NotApplied = countif(CAStatus == "notApplied"),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    SampleDevice = any(DeviceName),
    SampleOS = any(OperatingSystem),
    SampleBrowser = any(Browser),
    SampleTrust = any(TrustType),
    SampleCompliant = any(IsCompliant),
    SampleManaged = any(IsManaged),
    SampleApp = any(App),
    SampleResource = any(Resource),
    SampleClientApp = any(ClientApp),
    SampleAuthRequirement = any(AuthRequirement),
    SampleAuthPolicies = any(AuthRequirementPolicies),
    SampleCAStatus = any(CAStatus),
    SampleCAPolicies = any(CAPolicies),
    SampleMFA = any(MFA),
    SampleUserAgent = any(UserAgent),
    DeviceDetailObject = any(DeviceDetailParsed),
    ASN = any(ASN)
  by IPAddress, UserPrincipalName
| sort by LastSeen desc
| project
    IPAddress,
    ASN,
    UserPrincipalName,
    SuccessfulLogins,
    FailedLogins,
    CA_Applied_Success,
    CA_Applied_Failure,
    CA_NotApplied,
    FirstSeen,
    LastSeen,
    SampleDevice,
    SampleOS,
    SampleBrowser,
    SampleTrust,
    SampleCompliant,
    SampleManaged,
    SampleApp,
    SampleResource,
    SampleClientApp,
    SampleAuthRequirement,
    SampleAuthPolicies,
    SampleCAStatus,
    SampleCAPolicies,
    SampleMFA,
    SampleUserAgent,
    DeviceDetailObject
// NOTE: This query summarises non-interactive sign-in activity (such as token refreshes and service-to-service authentications) per user and IP over the past 30 days.
// It is intended for visibility and correlation purposes only. Do not use this query to audit interactive user behaviour or sign-in intent.
// Always validate with interactive sign-in logs (SigninLogs), raw activity logs, or session records where authentication context is critical.

2: Microsoft SharePoint, OneDrive, & Cloud Activity.

2.1: Summarise deleted SharePoint/OneDrive file activity by file type:

let FileActivity = 
    OfficeActivity
    | where UserId contains "FirstName.LastName"
    | where Operation has_any("FileVersionsAllDeleted", "FileDeleted", "FileRecycled", "FolderRecycled")
    | extend FileType = extract(@"\.(\w+)$", 1, SourceFileName), SiteUrl = Site_Url, SourceRelativeUrl = SourceRelativeUrl, Operation = Operation, RecordType = RecordType, OfficeWorkload = OfficeWorkload, EventSource = EventSource, ItemType = ItemType, SensitivityLabelId = SensitivityLabelId, UserId_ = UserId, ClientIP = ClientIP, UserAgent = UserAgent, IsManagedDevice = IsManagedDevice, Type = Type;
union
(
    FileActivity
    | summarize Count = count() 
    | extend FileType = "Total", FileNames = "", SiteUrl = "", SourceRelativeUrl = "", Operation = "", RecordType = "", OfficeWorkload = "", EventSource = "", ItemType = "", SensitivityLabelId = "", UserId_ = "", ClientIP = "", UserAgent = "", IsManagedDevice = "", Type = ""
),
(
    FileActivity
    | summarize Count = count(), FileNamesList = make_list(SourceFileName, 100000), SiteUrlList = make_list(SiteUrl, 100000), SourceRelativeUrlList = make_list(SourceRelativeUrl, 100000), OperationList = make_list(Operation, 100000), RecordTypeList = make_list(RecordType, 100000), OfficeWorkloadList = make_list(OfficeWorkload, 100000), EventSourceList = make_list(EventSource, 100000), ItemTypeList = make_list(ItemType, 100000), SensitivityLabelIdList = make_list(SensitivityLabelId, 100000), UserId_List = make_list(UserId_, 100000), ClientIPList = make_list(ClientIP, 100000), UserAgentList = make_list(UserAgent, 100000), IsManagedDeviceList = make_list(IsManagedDevice, 100000), TypeList = make_list(Type, 100000) by FileType
    | extend FileNames = strcat_array(FileNamesList, "\n"), SiteUrl = strcat_array(SiteUrlList, "\n"), SourceRelativeUrl = strcat_array(SourceRelativeUrlList, "\n"), Operation = strcat_array(OperationList, "\n"), RecordType = strcat_array(RecordTypeList, "\n"), OfficeWorkload = strcat_array(OfficeWorkloadList, "\n"), EventSource = strcat_array(EventSourceList, "\n"), ItemType = strcat_array(ItemTypeList, "\n"), SensitivityLabelId = strcat_array(SensitivityLabelIdList, "\n"), UserId_ = strcat_array(UserId_List, "\n"), ClientIP = strcat_array(ClientIPList, "\n"), UserAgent = strcat_array(UserAgentList, "\n"), IsManagedDevice = strcat_array(IsManagedDeviceList, "\n"), Type = strcat_array(TypeList, "\n")
    | project FileType, Count, FileNames, SiteUrl, SourceRelativeUrl, Operation, RecordType, OfficeWorkload, EventSource, ItemType, SensitivityLabelId, UserId_, ClientIP, UserAgent, IsManagedDevice, Type
)
| extend SortOrder = iif(FileType == "Total", 1000000, Count)
| order by SortOrder desc
| project-away SortOrder
| project Count, FileType, FileNames, SiteUrl, SourceRelativeUrl, Operation, RecordType, OfficeWorkload, EventSource, ItemType, SensitivityLabelId, UserId_, ClientIP, UserAgent, IsManagedDevice, Type

2.2: Summarise downloaded SharePoint/OneDrive file activity by file type:

let FileActivity = 
    OfficeActivity
    | where UserId contains "FirstName.LastName"
    | where Operation has_any("FileDownloaded")
    | extend FileType = extract(@"\.(\w+)$", 1, SourceFileName), SiteUrl = Site_Url, SourceRelativeUrl = SourceRelativeUrl, Operation = Operation, RecordType = RecordType, OfficeWorkload = OfficeWorkload, EventSource = EventSource, ItemType = ItemType, SensitivityLabelId = SensitivityLabelId, UserId_ = UserId, ClientIP = ClientIP, UserAgent = UserAgent, IsManagedDevice = IsManagedDevice, Type = Type;
union
(
    FileActivity
    | summarize Count = count() 
    | extend FileType = "Total", FileNames = "", SiteUrl = "", SourceRelativeUrl = "", Operation = "", RecordType = "", OfficeWorkload = "", EventSource = "", ItemType = "", SensitivityLabelId = "", UserId_ = "", ClientIP = "", UserAgent = "", IsManagedDevice = "", Type = ""
),
(
    FileActivity
    | summarize Count = count(), FileNamesList = make_list(SourceFileName, 100000), SiteUrlList = make_list(SiteUrl, 100000), SourceRelativeUrlList = make_list(SourceRelativeUrl, 100000), OperationList = make_list(Operation, 100000), RecordTypeList = make_list(RecordType, 100000), OfficeWorkloadList = make_list(OfficeWorkload, 100000), EventSourceList = make_list(EventSource, 100000), ItemTypeList = make_list(ItemType, 100000), SensitivityLabelIdList = make_list(SensitivityLabelId, 100000), UserId_List = make_list(UserId_, 100000), ClientIPList = make_list(ClientIP, 100000), UserAgentList = make_list(UserAgent, 100000), IsManagedDeviceList = make_list(IsManagedDevice, 100000), TypeList = make_list(Type, 100000) by FileType
    | extend FileNames = strcat_array(FileNamesList, "\n"), SiteUrl = strcat_array(SiteUrlList, "\n"), SourceRelativeUrl = strcat_array(SourceRelativeUrlList, "\n"), Operation = strcat_array(OperationList, "\n"), RecordType = strcat_array(RecordTypeList, "\n"), OfficeWorkload = strcat_array(OfficeWorkloadList, "\n"), EventSource = strcat_array(EventSourceList, "\n"), ItemType = strcat_array(ItemTypeList, "\n"), SensitivityLabelId = strcat_array(SensitivityLabelIdList, "\n"), UserId_ = strcat_array(UserId_List, "\n"), ClientIP = strcat_array(ClientIPList, "\n"), UserAgent = strcat_array(UserAgentList, "\n"), IsManagedDevice = strcat_array(IsManagedDeviceList, "\n"), Type = strcat_array(TypeList, "\n")
    | project FileType, Count, FileNames, SiteUrl, SourceRelativeUrl, Operation, RecordType, OfficeWorkload, EventSource, ItemType, SensitivityLabelId, UserId_, ClientIP, UserAgent, IsManagedDevice, Type
)
| extend SortOrder = iif(FileType == "Total", 1000000, Count)
| order by SortOrder desc
| project-away SortOrder
| project Count, FileType, FileNames, SiteUrl, SourceRelativeUrl, Operation, RecordType, OfficeWorkload, EventSource, ItemType, SensitivityLabelId, UserId_, ClientIP, UserAgent, IsManagedDevice, Type

3: Email Events.

3.1: Summarise email activity by sender domain (including URLs, attachments, recipients, and post-delivery actions.

let targetDomain = "domain.com";
let FilteredEmails = EmailEvents
| where SenderFromDomain == targetDomain;
let AttachmentInfo = EmailAttachmentInfo
| project NetworkMessageId, FileName, SHA256
| summarize AttachmentBlock = strcat_array(make_list(strcat("- ", FileName, " (SHA256: ", SHA256, ")")), "\n") by NetworkMessageId;
let UrlInfo = EmailUrlInfo
| project NetworkMessageId, Url
| extend SanitisedUrl = replace_string(replace_regex(Url, "\\.(com|net|org|co\\.uk|io|gov|uk|biz|edu|info|me|to|ai|careers)", "[.\\1]"), "://", "[://]");
let ClickInfo = UrlClickEvents
| project NetworkMessageId, Url
| summarize ClickedUrls = make_set(Url) by NetworkMessageId;
let UrlWithClicks = UrlInfo
| join kind=leftouter (ClickInfo) on NetworkMessageId
| extend ClickStatus = iff(set_has_element(ClickedUrls, Url), "Yes", "No")
| summarize UrlList = make_list(strcat(SanitisedUrl, "|", ClickStatus)) by NetworkMessageId;
let UrlBlockFormatted = UrlWithClicks
| mv-apply UrlList on (
    serialize
    | extend UrlIndex = row_number()
    | extend URLInfo = split(UrlList, "|")
    | extend UrlLine = strcat("URL ", tostring(UrlIndex), ": ", URLInfo[0], " | Clicked: ", URLInfo[1])
)
| summarize UrlCount = count(), UrlBlock = strcat_array(make_list(UrlLine), "\n") by NetworkMessageId;
let RecipientsByEmail = FilteredEmails
| project NetworkMessageId, RecipientEmailAddress
| summarize Recipients = make_list(RecipientEmailAddress) by NetworkMessageId
| extend RecipientCount = array_length(Recipients)
| extend RecipientBlock = iff(RecipientCount == 0, "Recipients: None", strcat("Recipients (", tostring(RecipientCount), "):\n", strcat_array(Recipients, "\n")));
let PostDeliveryActions = FilteredEmails
| project NetworkMessageId
| join kind=leftouter (
    EmailPostDeliveryEvents
    | project NetworkMessageId, Action, ActionTrigger, ActionType, ThreatTypes
) on NetworkMessageId
| summarize PostDeliveryAction = strcat_array(make_list(strcat("Action: ", Action, " | Trigger: ", ActionTrigger, " | Type: ", ActionType, " | Threats: ", tostring(ThreatTypes))), "\n") by NetworkMessageId
| extend PostDeliverySummary = iff(isempty(PostDeliveryAction), "🚨 No post-delivery actions found 🚨", strcat("🚨 Post-delivery action(s) 🚨\n", PostDeliveryAction));
FilteredEmails
| project Subject, NetworkMessageId, InternetMessageId, Timestamp, SenderFromAddress, DeliveryAction, EmailLanguage, RecipientEmailAddress
| join kind=leftouter (AttachmentInfo) on NetworkMessageId
| join kind=leftouter (UrlBlockFormatted) on NetworkMessageId
| join kind=leftouter (RecipientsByEmail) on NetworkMessageId
| join kind=leftouter (PostDeliveryActions) on NetworkMessageId
| extend AttachmentText = iff(isnull(AttachmentBlock), "Attachments: None", strcat("Attachments:\n", AttachmentBlock))
| extend UrlText = iff(isnull(UrlBlock), "URLs: None", strcat("URLs (", tostring(UrlCount), "):\n", UrlBlock))
| extend RecipientText = iff(isnull(RecipientBlock), "Recipients: None", RecipientBlock)
| extend PostDeliveryText = iff(isnull(PostDeliverySummary), "🚨 No post-delivery actions found 🚨", PostDeliverySummary)
| extend Template = "==== EMAIL ====\n{postdelivery}\nTime: {time}\nSender: {sender}\nDelivery: {delivery}\nLanguage: {lang}\n{recipients}\n{urls}\n{attachments}\nInternet Msg ID: {imid}\nNetwork Msg ID: {nmid}"
| extend Step1 = replace_string(Template, "{time}", format_datetime(Timestamp, "yyyy-MM-dd HH:mm:ss"))
| extend Step2 = replace_string(Step1, "{sender}", SenderFromAddress)
| extend Step3 = replace_string(Step2, "{delivery}", DeliveryAction)
| extend Step4 = replace_string(Step3, "{lang}", EmailLanguage)
| extend Step5 = replace_string(Step4, "{recipients}", RecipientText)
| extend Step6 = replace_string(Step5, "{urls}", UrlText)
| extend Step7 = replace_string(Step6, "{attachments}", AttachmentText)
| extend Step8 = replace_string(Step7, "{postdelivery}", PostDeliveryText)
| extend Step9 = replace_string(Step8, "{imid}", tostring(InternetMessageId))
| extend EmailDetail = replace_string(Step9, "{nmid}", tostring(NetworkMessageId))
| summarize Emails = make_list(EmailDetail) by Subject;

4: Network Traffic Logs.

4.1: Detect suspicious or anomalous activity in IIS logs.

W3CIISLog
|where * =="1.1.1.1"

//further details

W3CIISLog
|where sIP =="1.1.1.1"
|summarize attempts=count() by bin(TimeGenerated,1h)
|render timechart

5. Security Events & AD.

5.1: Retrieve details of user(s) added to security group(s).

SecurityEvent
| where EventID == 4728
| project-rename AD_Group_Name = TargetAccount
| project TimeGenerated, Account, AccountType, AD_Group_Name, Channel, Task, EventSourceName, EventID, Activity, MemberName, MemberSid, SubjectAccount, SubjectDomainName, SubjectUserName, SubjectUserSid, TargetUserName

5.2: Identify recent service installations over a 10-day period.

SecurityEvent
| where EventID == 4697
| where TimeGenerated > ago(10d)
| project TimeGenerated, Computer, Account, ServiceName, ServiceFileName, ServiceType, ServiceStartType, ServiceAccount, SubjectUserName, SubjectDomainName
| sort by TimeGenerated desc

5.3: Investigate NTDS.dit access.

DeviceFileEvents
| where FileName =~ "ntds.dit"
| where InitiatingProcessFileName !in~ ("ntdsutil.exe", "esentutl.exe", "mimikatz.exe", "powershell.exe", "cmd.exe")
| summarize count() by InitiatingProcessFileName, InitiatingProcessCommandLine
// Identifies processes creating/accessing `ntds.dit` and excludes known credential dumping tools.
// Useful for confirming benign process (e.g., `dockerd.exe`) is responsible.
// Does not show timeline or file path details — use with path-based query.
10
Subscribe to my newsletter

Read articles from Ciaran Doherty, AfCIIS, MBCS directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ciaran Doherty, AfCIIS, MBCS
Ciaran Doherty, AfCIIS, MBCS