# T1199 - Trusted Relationship

## SOC Recommendation
Investigate Trusted Relationship activity in the context of Initial Access: confirm scope, affected host/identity, and whether it matches expected administrative behaviour before deciding this is benign.

## D3FEND Mappings
| D3FEND Technique | Relationship | Practical SOC Action |
|---|---|---|
| User Geolocation Logon Pattern Analysis | Detect | Monitor for User Geolocation Logon Pattern Analysis indicators relevant to this technique. |
| Session Termination | Evict | Use Session Termination to remove the adversary's foothold once this technique is confirmed. |
| Network Traffic Filtering | Isolate | Apply Network Traffic Filtering to contain the blast radius once this technique is observed. |

## Investigation Steps
1. Review Defender for Endpoint / Defender XDR alerts and timeline for the affected host or identity.
2. Check Sentinel analytics rules and incidents correlated with this technique.
3. Review Entra ID sign-in and audit logs if the technique involves an identity or cloud resource.

## Evidence to Collect
- Host or resource affected
- Account or identity involved
- Timestamp of the activity
- Related process, file, or network artifact
- Any preceding or follow-on alerts

## Response Actions
| Action | Risk | Automation Safe | Approval Required |
|---|---|---|---|
| Revoke the third-party account's access temporarily pending | Medium | No | Yes |
| Contact vendor security team directly to confirm whether the | Low | Yes | No |
| Review all actions taken by the vendor account in the last 3 | Low | Yes | No |
| If vendor compromise confirmed: treat as supply chain incide | Low | Yes | No |

## KQL
```kql
let minimumCountries = 2;
let deltaThreshold = 95;
let countryPrevalenceThreshold = 10;
let projectedEndTime = 60m;
let queryfrequency = 1d;
let queryperiod = 14d;
let TrustedRelationshipAllowIpWatchlist =
    _GetWatchlist('wl-baseline-ia019-trusted-relationship-allow-ip')
    | extend WatchlistIP = tostring(column_ifexists("SearchKey", column_ifexists("remoteip", column_ifexists("TrustedRelationshipAllowIP", column_ifexists("IPAddress", "")))))
    | where isnotempty(WatchlistIP)
    | project WatchlistIP;
let officeActivitySource =
    union isfuzzy=true OfficeActivity,
    (datatable(TimeGenerated:datetime, Operation:string, UserId:string)[]);
let aadFunc = (tableName: string) {
    let signinData =
        union isfuzzy=true
            (SigninLogs | extend SourceTable = "SigninLogs"),
            (AADNonInteractiveUserSignInLogs | extend SourceTable = "AADNonInteractiveUserSignInLogs"),
            (datatable(TimeGenerated:datetime, AppDisplayName:string, ConditionalAccessStatus:string, LocationDetails:dynamic, IPAddress:string, UserPrincipalName:string, SourceTable:string)[])
        | where SourceTable == tableName
        | where TimeGenerated > ago(queryperiod)
        | where AppDisplayName has "Teams" and ConditionalAccessStatus =~ "success"
        | extend Country = tostring(todynamic(LocationDetails)['countryOrRegion'])
        | where isnotempty(Country) and isnotempty(IPAddress)
        | where not(IPAddress in (TrustedRelationshipAllowIpWatchlist));
    let countryPrevalence =
        signinData
        | summarize CountCountrySignin = count() by Country
        | extend TotalSignin = toscalar(signinData | summarize count())
        | extend CountryPrevalence = toreal(CountCountrySignin) / toreal(TotalSignin) * 100;
    let userIpSignin =
        signinData
        | summarize CountIPSignin = count(), Country = any(Country), ListSigninTimeGenerated = make_list(TimeGenerated) by IPAddress, UserPrincipalName;
    let userIpDelta =
        userIpSignin
        | summarize MaxIPSignin = max(CountIPSignin), MinIPSignin = min(CountIPSignin), DistinctCountries = dcount(Country), Countries = make_set(Country) by UserPrincipalName
        | extend UserIPDelta = toreal(MaxIPSignin - MinIPSignin) / toreal(MaxIPSignin) * 100;
    officeActivitySource
    | where TimeGenerated > ago(queryfrequency)
    | where Operation in~ ("TeamsAdminAction", "MemberAdded", "MemberRemoved", "MemberRoleChanged", "AppInstalled", "BotAddedToTeam")
    | project OperationTimeGenerated = TimeGenerated, UserId = tolower(UserId), Operation
    | join kind=inner (
        userIpDelta
        | where DistinctCountries >= minimumCountries
        | where UserIPDelta >= deltaThreshold
        | join kind=leftouter userIpSignin on UserPrincipalName
        | join kind=leftouter countryPrevalence on Country
        | where CountryPrevalence < countryPrevalenceThreshold
        | project UserPrincipalName, SuspiciousIP = IPAddress, UserIPDelta, DistinctCountries, Countries,
                  SuspiciousSigninCountry = Country, SuspiciousCountryPrevalence = CountryPrevalence,
                  EventTimes = ListSigninTimeGenerated
    ) on $left.UserId == $right.UserPrincipalName
    | mv-expand SigninTimeGenerated = EventTimes
    | extend SigninTimeGenerated = todatetime(SigninTimeGenerated)
    | where OperationTimeGenerated between (SigninTimeGenerated .. (SigninTimeGenerated + projectedEndTime))
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
union isfuzzy=true aadSignin, aadNonInt
| summarize arg_max(SigninTimeGenerated, *) by UserPrincipalName, SuspiciousIP, OperationTimeGenerated
| summarize
    ActivitySummary = make_bag(pack(tostring(SigninTimeGenerated), pack("Operation", tostring(Operation), "OperationTime", OperationTimeGenerated))),
    Countries = any(Countries),
    DistinctCountries = max(DistinctCountries),
    UserIPDelta = max(UserIPDelta)
    by UserPrincipalName, SuspiciousIP, SuspiciousSigninCountry, SuspiciousCountryPrevalence
| extend IPCustomEntity = SuspiciousIP,
         AccountCustomEntity = UserPrincipalName
```
```kql
//The bigger the window the better the data sample size, as we use IP prevalence, more sample data is better.
//The minimum number of countries that the account has been accessed from [default: 2]
let minimumCountries = 2;
//The delta (%) between the largest in-use IP and the smallest [default: 95]
let deltaThreshold = 95;
//The maximum (%) threshold that the country appears in login data [default: 10]
let countryPrevalenceThreshold = 10;
//The time to project forward after the last login activity [default: 60min]
let projectedEndTime = 60m;
let queryfrequency = 1d;
let queryperiod = 14d;
let aadFunc = (tableName: string) {
    // Get successful signins to Teams
    let signinData =
        table(tableName)
        | where TimeGenerated > ago(queryperiod)
        | where AppDisplayName has "Teams" and ConditionalAccessStatus =~ "success"
        | extend Country = tostring(todynamic(LocationDetails)['countryOrRegion'])
        | where isnotempty(Country) and isnotempty(IPAddress);
    // Calculate prevalence of countries
    let countryPrevalence =
        signinData
        | summarize CountCountrySignin = count() by Country
        | extend TotalSignin = toscalar(signinData | summarize count())
        | extend CountryPrevalence = toreal(CountCountrySignin) / toreal(TotalSignin) * 100;
    // Count signins by user and IP address
    let userIpSignin =
        signinData
        | summarize CountIPSignin = count(), Country = any(Country), ListSigninTimeGenerated = make_list(TimeGenerated) by IPAddress, UserPrincipalName;
    // Calculate delta between the IP addresses with the most and minimum activity by user
    let userIpDelta =
        userIpSignin
        | summarize MaxIPSignin = max(CountIPSignin), MinIPSignin = min(CountIPSignin), DistinctCountries = dcount(Country), make_set(Country) by UserPrincipalName
        | extend UserIPDelta = toreal(MaxIPSignin - MinIPSignin) / toreal(MaxIPSignin) * 100;
    // Collect Team operations the user account has performed within a time range of the suspicious signins
    OfficeActivity
    | where TimeGenerated > ago(queryfrequency)
    | where Operation in~ ("TeamsAdminAction", "MemberAdded", "MemberRemoved", "MemberRoleChanged", "AppInstalled", "BotAddedToTeam")
    | where not (Operation in~ ("MemberAdded", "MemberRemoved") and CommunicationType in~ ("GroupChat", "OneonOne")) // These events have been noisy and are related to initiaing chat conversation and not admin operations.
    | project OperationTimeGenerated = TimeGenerated, UserId = tolower(UserId), Operation
    | join kind = inner(
        userIpDelta
        // Check users with activity from distinct countries
        | where DistinctCountries >= minimumCountries
        // Check users with high IP delta
        | where UserIPDelta >= deltaThreshold
        // Add information about signins and countries
        | join kind = leftouter userIpSignin on UserPrincipalName
        | join kind = leftouter countryPrevalence on Country
        // Check activity that comes from nonprevalent countries
        | where CountryPrevalence < countryPrevalenceThreshold
        | project
            UserPrincipalName,
            SuspiciousIP = IPAddress,
            UserIPDelta,
            SuspiciousSigninCountry = Country,
            SuspiciousCountryPrevalence = CountryPrevalence,
            EventTimes = ListSigninTimeGenerated
    ) on $left.UserId == $right.UserPrincipalName
    // Check the signins occured 60 min before the Teams operations
    | mv-expand SigninTimeGenerated = EventTimes
    | extend SigninTimeGenerated = todatetime(SigninTimeGenerated)
    | where OperationTimeGenerated between (SigninTimeGenerated .. (SigninTimeGenerated + projectedEndTime))
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
union isfuzzy=true aadSignin, aadNonInt
| summarize arg_max(SigninTimeGenerated, *) by UserPrincipalName, SuspiciousIP, OperationTimeGenerated
| summarize
    ActivitySummary = make_bag(pack(tostring(SigninTimeGenerated), pack("Operation", tostring(Operation), "OperationTime", OperationTimeGenerated)))
    by UserPrincipalName, SuspiciousIP, SuspiciousSigninCountry, SuspiciousCountryPrevalence
| extend AccountName = tostring(split(UserPrincipalName, "@")[0]), AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1])
```
```kql
// Add any known allowed sources and source locations to the filter below (the NuGet Gallery has been added here as an example).
let allowed_sources = dynamic(["NuGet Gallery"]);
let allowed_locations = dynamic(["https://api.nuget.org/v3/index.json"]);
ADOAuditLogs
// Look for feeds created or modified at either the organization or project level
| where OperationName matches regex "Artifacts.Feed.(Org|Project).Modify"
| where Details has "UpstreamSources, added"
| extend FeedName = tostring(Data.FeedName)
| extend FeedId = tostring(Data.FeedId)
| extend UpstreamsAdded = Data.UpstreamsAdded
// As multiple feeds may be added expand these out
| mv-expand UpstreamsAdded
// Only focus on external feeds
| where UpstreamsAdded.UpstreamSourceType !~ "internal"
| extend SourceLocation = tostring(UpstreamsAdded.Location)
| extend SourceName = tostring(UpstreamsAdded.Name)
// Exclude sources and locations in the allow list
| where SourceLocation !in (allowed_locations) and SourceName !in (allowed_sources)
| extend SourceProtocol = tostring(UpstreamsAdded.Protocol)
| extend SourceStatus = tostring(UpstreamsAdded.Status)
| project-reorder TimeGenerated, OperationName, ScopeDisplayName, ProjectName, FeedName, SourceName, SourceLocation, SourceProtocol, ActorUPN, UserAgent, IpAddress
| extend timestamp = TimeGenerated
| extend AccountName = tostring(split(ActorUPN, "@")[0]), AccountUPNSuffix = tostring(split(ActorUPN, "@")[1])
```

## Escalation Criteria
- Trusted Relationship activity observed on a privileged account or critical system.
- Activity follows or precedes other suspicious behaviour in the same investigation.
- Automated triage cannot confidently rule out malicious intent.

## False Positive Considerations
- Legitimate administrative or maintenance activity matching this pattern.
- Approved security testing or red team exercise.
- Known benign software producing similar telemetry.

Generated by SOC Response Atlas by Basyrix.
