Threat Hunting

What Is Threat Modeling in Cybersecurity?

HuntRule Team · · 9 min read

An architectural scale model of a building with one wall removed, lit by a single work lamp so the interior corridors are visible
On this page

Threat modeling is four questions. What are we working on, what can go wrong, what are we going to do about it, and did we do a good job.

Adam Shostack published that frame in Threat Modeling: Designing for Security (2014). The Threat Modeling Manifesto, a 2020 document Shostack co-authored, adds one word to the fourth question: did we do a good enough job. Shostack kept the terse version. Either phrasing works. The frame is not a methodology, it is the shape a methodology has to fill.

Nearly everything written about threat modeling assumes you are shipping an application and the deliverable is a set of code changes. This post is for the other side of the house. You are not fixing the code. You are deciding which detection to write next, and the deliverable is a gap list.

STRIDE, and what it is for

Loren Kohnfelder and Praerit Garg wrote STRIDE at Microsoft in 1999, and it later shipped inside the Microsoft Security Development Lifecycle. Six categories, each named after the security property it violates.

  • Spoofing violates authentication.

  • Tampering violates integrity.

  • Repudiation violates non-repudiation.

  • Information disclosure violates confidentiality.

  • Denial of service violates availability.

  • Elevation of privilege violates authorization.

STRIDE is a prompt list. Point it at one element in a data flow diagram and it asks six questions you would otherwise forget to ask. It does not rank anything, it does not know what your logging looks like, and it will not tell you where to spend the next sprint.

Two other methodologies come up often enough to name. PASTA (Process for Attack Simulation and Threat Analysis), from Tony UcedaVélez and Marco Morana, runs seven stages from business objectives down to risk and impact. Attack trees, from Bruce Schneier in 1999, decompose one attacker goal into sub-goals with AND/OR logic. Both are heavier than STRIDE, and neither produces a detection backlog on its own.

Modeling an environment instead of an application

The questions stay the same. What you enumerate changes.

An appsec threat model enumerates elements in a data flow diagram: processes, data stores, flows, trust boundaries. A detection threat model walks the same boundaries and asks a different question at each one. If an attacker crossed here, what record would exist, and in which log.

Three asset classes carry most of the weight in an environment model.

  • Identity. Every principal that can act, human or machine, and every path by which one principal becomes another.

  • Telemetry. The logs are themselves an asset. If they can be disabled, shortened or were never collected, everything downstream is theatre.

  • Administrative paths. The routes that legitimately hold privilege, because those are the routes an attacker prefers over anything novel.

The output is a ranked list of attacker paths with a yes or no beside each one. Can we see this today, and with which telemetry. That list is the point. If you are new to turning that list into rules, detection engineering covers the pipeline it feeds.

What are we working on

The system below is a composite built for this post, not a customer environment. It is a shape you will recognise.

A private GitHub repository, acme-corp/payments-api, holds a GitHub Actions workflow that builds a container and deploys it to ECS in a production AWS account. There are no static AWS access keys anywhere. The workflow authenticates with GitHub's OIDC provider and assumes an IAM role named prod-deploy. The trust policy on that role is the entire authorization decision.

{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::012345678901:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:acme-corp/payments-api:*"
    },
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    }
  }
}

The wildcard in that sub condition is the first finding. repo:acme-corp/payments-api:* matches every ref in the repository, not just main. Datadog Security Labs published research showing IAM roles in the wild where the sub condition was missing altogether, which lets a GitHub Actions workflow in any repository assume the role and retrieve credentials.

Four trust boundaries fall out of that description.

  1. Write access to the repository, into the workflow file.

  2. The GitHub OIDC provider, into the sub claim AWS trusts.

  3. The runner process, which holds the credentials in memory for the job's lifetime.

  4. The IAM policy attached to prod-deploy, into every AWS API it can call.

What can go wrong

CloudTrail writes an AssumeRoleWithWebIdentity event on every successful assumption. The record shape below is from the Datadog Security Labs writeup, with their placeholders left as published.

{
  "userIdentity": {
    "type": "WebIdentityUser",
    "userName": "repo:SOURCE-ORG/SOURCE-REPO:ref:refs/heads/BRANCH",
    "identityProvider": "arn:aws:iam::012345678901:oidc-provider/token.actions.githubusercontent.com"
  },
  "eventSource": "sts.amazonaws.com",
  "eventName": "AssumeRoleWithWebIdentity",
  "requestParameters": {
    "roleArn": "arn:aws:iam::012345678901:role/github-actions-role",
    "roleSessionName": "MySessionName",
    "durationSeconds": 3600
  },
  "responseElements": {
    "subjectFromWebIdentityToken": "repo:SOURCE-ORG/SOURCE-REPO:ref:refs/heads/BRANCH",
    "provider": "arn:aws:iam::012345678901:oidc-provider/token.actions.githubusercontent.com",
    "audience": "sts.amazonaws.com"
  }
}

responseElements.subjectFromWebIdentityToken carries the repository and the ref that got the credentials. Almost nobody alerts on that field. It is the highest-value string in the whole pipeline.

A dark concrete corridor lined with identical wall-mounted camera brackets, one bracket empty and unlit

Can we see it today

This is the deliverable. Every row is an attacker path through one of the four boundaries, the telemetry that would carry it, and an honest answer.

Attacker path

ATT&CK

Telemetry

Detected today

Push to a non-main branch that the wildcard sub still matches, workflow runs, credentials issued

T1195.002

GitHub audit git.push, workflows.created_workflow_run, CloudTrail AssumeRoleWithWebIdentity

No. Nothing compares the subject claim to an allowlist

Trust policy edited to widen or drop the sub condition

T1078.004

CloudTrail UpdateAssumeRolePolicy on iam.amazonaws.com

No. IAM policy edits go to a compliance dashboard, not a queue

Register a self-hosted runner to catch jobs and read the OIDC token

T1550.001

GitHub audit repo.register_self_hosted_runner, org.runner_group_updated

Yes. SigmaHQ ships a rule at low severity

Add or overwrite an Actions secret to exfiltrate it on the next run

T1552.001

GitHub audit repo.create_actions_secret, org.create_actions_secret

Yes. SigmaHQ ships a rule

Delete or override branch protection so unreviewed code reaches the deploy branch

T1685

GitHub audit protected_branch.destroy, protected_branch.policy_override

No

Use prod-deploy for IAM instead of deploying: CreateAccessKey, CreateUser

T1098.001, T1136.003

CloudTrail iam.amazonaws.com with userIdentity.sessionContext.sessionIssuer.userName

Partial. The SigmaHQ IAM backdoor rule fires but is not scoped to CI roles

Stop the trail before the noisy part of the intrusion

T1685.002

CloudTrail StopLogging, DeleteTrail, UpdateTrail

Yes. Standard coverage

Run a payload on the runner during the job

T1059.004

None on GitHub-hosted runners

No, and not fixable. Ephemeral VMs you do not own and cannot instrument

Two of those rows are structural gaps rather than missing rules. GitHub Git events (git.push, git.clone, git.fetch) are retained for seven days on GitHub Enterprise Cloud, far shorter than the rest of the audit log, and are not available in the audit log web interface at all, so a weekly query will miss them. And there is no host telemetry on GitHub-hosted runners at all. Moving to self-hosted runners buys EDR coverage and costs a new persistence surface, a trade the model should state rather than resolve.

The rule that came out of it

Row one is the cheapest fix and had no coverage. The subject claim is already in CloudTrail, so the rule is an allowlist comparison.

title: AWS Deploy Role Assumed From Unexpected GitHub Actions Subject
id: 7b3e9c41-2a6d-4f18-9c05-8de4b1f7a2c3
status: experimental
description: |
  Detects sts:AssumeRoleWithWebIdentity against a GitHub Actions deploy role where the
  OIDC subject claim is not one of the repository and ref pairs the role is meant to
  serve. Fires on trust policy drift, on an over-broad StringLike condition, and on a
  workflow running from an unexpected branch, tag or environment.
references:
    - https://securitylabs.datadoghq.com/articles/exploring-github-to-aws-keyless-authentication-flaws/
    - https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
author: HuntRule
date: 2026-07-31
tags:
    - attack.initial-access
    - attack.persistence
    - attack.t1078.004
logsource:
    product: aws
    service: cloudtrail
detection:
    selection:
        eventSource: 'sts.amazonaws.com'
        eventName: 'AssumeRoleWithWebIdentity'
        requestParameters.roleArn|endswith: ':role/prod-deploy'
    filter_main_expected_subject:
        responseElements.subjectFromWebIdentityToken:
            - 'repo:acme-corp/payments-api:ref:refs/heads/main'
            - 'repo:acme-corp/payments-api:environment:production'
    condition: selection and not filter_main_expected_subject
falsepositives:
    - A release branch or deployment environment added to the trust policy without updating this allowlist
    - Tag-triggered deploys, which produce repo:acme-corp/payments-api:ref:refs/tags/<tag>
    - Repository renamed or transferred, which changes the subject string
level: high

What it catches: any credential issuance to the deploy role from a ref, environment or repository that is not on the list. That covers the wildcard sub case, a trust policy someone widened during an outage, and a workflow triggered from a fork if the trigger allows it.

What it misses: everything after the credentials are issued. If an attacker pushes to main through a legitimate merge, the subject matches and this rule is silent. It also cannot see a stolen id_token replayed against a second role in another account, because that produces a different roleArn and needs its own selection. And it says nothing about what prod-deploy did once it held credentials, which is row six's problem.

The allowlist is the maintenance cost. Every new deploy environment is a false positive until someone edits the rule, which is the same maintenance the trust policy already needs. Broader cloud identity rules live under /rules?domain=cloud.

The failure mode nobody admits

Most threat models produce a diagram, a spreadsheet of findings, and no follow-through. The diagram goes stale the week the architecture changes and nobody opens it again. The exercise above is worth the afternoon only if the gap column turns into tickets with owners.

Two cheap habits help. Store the gap table in the same repository as the detection content, so a pull request that changes the pipeline lands next to the rules that watch it. And re-run the model on a trigger rather than a calendar: a new trust policy, a new runner group, a new production account. Annual review dates get skipped. Change triggers do not.

Summary

Threat modeling asks what can go wrong before it does, and for a detection team the answer belongs in a gap list rather than a diagram. Shostack's four questions give the shape, STRIDE gives the prompt list, and the environment version swaps application components for identity, telemetry and administrative paths. One pipeline produced eight attacker paths, four uncovered, two of them structural gaps no rule will close. One of the four became a Sigma rule that reads a field CloudTrail was already writing.

Collect these before you model a pipeline like this one.

AWS CloudTrail management events (all regions), specifically:
  sts.amazonaws.com     AssumeRoleWithWebIdentity, AssumeRole
  iam.amazonaws.com     UpdateAssumeRolePolicy, CreateAccessKey, CreateUser, AttachRolePolicy
  cloudtrail.amazonaws.com  StopLogging, DeleteTrail, UpdateTrail

GitHub Enterprise Cloud audit log streaming, specifically:
  workflows.created_workflow_run
  protected_branch.destroy, protected_branch.policy_override
  repo.register_self_hosted_runner, org.runner_group_updated
  repo.create_actions_secret, org.create_actions_secret, environment.create_actions_secret
  git.push  (7 day retention, REST API and streaming only)

Fields the rules depend on:
  responseElements.subjectFromWebIdentityToken
  userIdentity.userName
  userIdentity.sessionContext.sessionIssuer.userName
  requestParameters.roleArn

Related articles