PRAETORIAN/Black Hat USA 2026
Black Hat USA 2026 · Arsenal

Trajan

A multi-platform CI/CD vulnerability detection toolkit

GitHub GitLab Azure DevOps
Praetorian
PRAETORIAN/Black Hat USA 2026/WHO WE ARE
Introductions

Who we are

Ranganatha Rao Sridhar
Lead Security Engineer · Praetorian
Rahul Saranjame
Lead Security Engineer · Praetorian
Tanishq Rupaal
Staff Security Engineer · Praetorian
About Praetorian

Praetorian is an offensive security firm building autonomous, AI-driven attack capabilities. Our researchers and agents emulate real adversaries to find and prove exploitable risk across code, cloud, and CI/CD, before attackers get there first.

PRAETORIAN/Black Hat USA 2026/AGENDA
What we'll cover

Agenda

01
The problem
02
How Trajan works
03
Scenario I: GitHub Actions
04
Scenario II: Azure DevOps
05
Get the tool
PRAETORIAN/THE PROBLEM
The problem

Today's coverage has blind spots

Fragmented tooling: a different scanner per platform, with incomplete parity across GitHub, GitLab and Azure DevOps
Blind to cross-repo and cross-platform chains: a credential taken in one place unlocks the next, and a compromise that lands on a runner owned by another cloud is a hop per-repo scanners never follow
Ignores org-level context: branch protection, secret scope and visibility, job-authorization scope, OIDC trust and runner configuration are never factored in
Siloed to a single repo, default branch only: scans stop at one repository's main
PRAETORIAN/HOW TRAJAN WORKS
How Trajan works

One pipeline

05
Attack
Prove a finding is reachable. Inert without --execute.
trajan <platform> attack
01
Collect
Pull CI/CD config to disk: pipelines, secrets metadata, branch policies, feeds, org settings.
trajan <platform> collect
02
Normalize
Raw config becomes typed facts. Classify triggers, sinks and gates; derive taint edges.
trajan <platform> normalize
03
Scan
Evaluate detections over the facts: a declarative DSL plus YAML rule templates.
trajan <platform> scan
04
Emit findings
Render evidenced findings: the source to sink path, with remediation.
trajan <platform> report
06
Graph
Typed nodes and edges per principal and branch, pushed to Neo4j.
trajan <platform> graph
PRAETORIAN/THE ATTACK MODULE/GITHUB
Trajan · the attack module · GitHub

The attack module

The attack plan is a YAML file that's made up of:
Primitives
The steps, one authorized API interaction each
Embedded payloads
The code a step plants for the pipeline to run
Template system
Renders a payload into shell or workflow YAML
PRAETORIAN/SCENARIO I · GITHUB/THE SETUP
Scenario I · GitHub · the setup

The environment

portus-labs · private
portus-clipublic · the entry point · @claude review bot
PLATFORM_BOT_PATorg secret
shared-workflowsthe hub · reusable deploy.yml · protect-main and CODEOWNERS
payments-apithe consumer · production deploys
Runner groupDefault · visibility: all
AWSdeploy role
vm-trajan-devopsself-hosted runner · an Azure VM
deploy.yml
write
calls deploy.yml@main · secrets: inherit
OIDC · deploy role
holds the runner
PRAETORIAN/SCENARIO I · GITHUB/1 OF 4
Scenario I · hop 1 · the entry

portus-cli

The entry point · the attacker holds read
Repo overview / context
Runs a @claude review bot on issue_comment, so any comment can start a job
Its deploy path holds the org secret PLATFORM_BOT_PAT
The attacker is an outside collaborator with read, so they cannot push a branch
Core weakness
The PR head is checked out at the workspace root, so a fork's CLAUDE.md is read off disk, past the action's text sanitizer
Bash with no command allowlist, and the action mints a GitHub App token carrying contents:write and workflows:write, which the workflow's own permissions: block does not constrain
Fork PRs run nothing, so the injection makes a trusted identity launder the payload into an in-repo branch, which does run and hands over the PAT
PRAETORIAN/SCENARIO I · GITHUB/2 OF 4
Scenario I · hop 2 · the hub

shared-workflows

The hub · every repo calls its reusable deploy
Repo overview / context
protect-main ruleset active, no bypass actors: one approval plus code-owner review
CODEOWNERS covers /.github/workflows/ and /README.md
The attacker holds write, but write is not merge
Core weakness
scripts/ is not code-owned, and scripts/setup-env.sh runs in every consumer's deploy job
Two branches, and they cannot be one. A carries the poisoned script and needs any one approval; B is pushed, never opened as a PR, and its on: push run uses GITHUB_TOKEN to approve and merge A
Putting B's workflow in the PR would drag /.github/workflows/ into the diff and re-arm CODEOWNERS. The gate is satisfied, not bypassed
PRAETORIAN/SCENARIO I · GITHUB/3 OF 4
Scenario I · hop 2b · the detonation

payments-api

The consumer · where the poisoned hub runs
Repo overview / context
Calls the hub's reusable deploy on every push to main, which is not protected
Deploys to production through an OIDC role, with no long-lived AWS keys
Holds its own secrets as well: DATADOG_API_KEY, SLACK_WEBHOOK_URL
Core weakness
The reference is unpinned: deploy.yml@main is a mutable ref, so whoever lands code on the hub's main chooses what this job runs
secrets: inherit is a blanket grant to code this repo does not own, and it hands over every secret, not only the AWS ones
So the poisoned script runs holding both the org PAT and an OIDC token, and mints credentials for the production deploy role
PRAETORIAN/SCENARIO I · GITHUB/4 OF 4
Scenario I · hop 3 · the reach

vm-trajan-devops

The self-hosted runner · an Azure VM
Overview / context
Org runner group Default has visibility: all
members_can_create_private_repositories: true
A persistent host, not a fresh container, so filesystem and process state survive the job
Core weakness
A repo created after the runner was registered inherits it, with no grant and no configuration change
A credential granted nothing on this host reaches it anyway, and execution there reaches the VM's managed identity through IMDS
Out comes a live Azure DevOps token: a boundary GitHub permissions do not describe, and revoking the PAT does not close it
PRAETORIAN/SCENARIO I · THE ATTACK PATH/LIVE
Scenario I · the attack path

Read access to a cloud identity

portus-labs
Attackerread only
portus-clithe @claude bot launders the fork's code in-repo
PLATFORM_BOT_PATexfiltrated over DNS
shared-workflowsscripts/ poisoned · self-approved past main
payments-apiunpinned · secrets: inherit
platform-selftestcreated after the fact · runs-on: self-hosted
AWSdeploy role
vm-trajan-devopspersistent Azure VM · IMDS
PortusTrajanAzure DevOps · Scenario II starts here
1 · fork PR with Claude Code prompt injection
2 · PAT exfiltrated
3 · poison scripts/
4 · detonates
5 · OIDC
6 · new repo
7 · self-hosted
8 · ADO token
PRAETORIAN/SCENARIO II · AZURE DEVOPS/THE SETUP
Scenario II · Azure DevOps · the setup

Into Azure DevOps

PortusTrajan · scope-to-project OFF
mi-trajan-devops-vmthe Azure VM's managed identity · an org member
portus-payments-ciCI trigger · maps System.AccessToken · collection scope
portus-packagesorg feed · immutable versions · @Release · Contributor is the Project Collection Build Service
can queue any pipeline
publishes
PRAETORIAN/SCENARIO II · AZURE DEVOPS/1 OF 2
Scenario II · target pipeline

portus-payments-ci

The entry pipeline · queueable org-wide
Pipeline overview / context
CI-triggered pipeline in project portus-payments
Declared job-auth scope is Project Collection
main is branch-protected: a blocking policy requiring 2 reviewers
Core weakness
It maps System.AccessToken into a publish step with no gate on the path
A branch is not a boundary: policy gates merges, not builds. Queue a build against your own branch and Azure DevOps runs your azure-pipelines.yml
With the org toggle off, that token is the Project Collection Build Service, the org's most privileged automation identity
PRAETORIAN/SCENARIO II · AZURE DEVOPS/2 OF 2
Scenario II · the crown

portus-packages

The shared feed · the whole org restores from it
Feed overview / context
The org's private package registry for npm, NuGet, PyPI and Maven: whoever can publish into it puts code on every machine that restores
Published versions are immutable: a version can never be overwritten, so whatever lands in the feed is what everyone gets from then on
@portus/payments-client@1.0.0 is already published and pinned to @Release, so the name teams restore is a real one
Core weakness
The hijacked build holds Contributor, the feed's publish role, granted to the Project Collection Build Service
Direct feed poisoning: push the malicious version straight into the private feed, under a name teams already restore, at a version their float will pick
@Release is a label, not a gate: promotion is a plain Contributor action, a single API call, and it makes the poisoned version look sanctioned
PRAETORIAN/SCENARIO II · THE ATTACK PATH/LIVE
Scenario II · the attack path

One identity to the supply chain

PortusTrajan · scope-to-project OFF
mi-trajan-devops-vmbearer token from hop 3
portus-payments-ciattacker branch · now the Project Collection Build Service
portus-packagesmalicious version pushed in · immutable · promoted to @Release
hop 3 of Scenario I
1 · queue any pipeline against an attacker branch
2 · push straight into the feed
3 · promote to @Release
PRAETORIAN/Black Hat USA 2026/OPEN SOURCE
Open source

Get Trajan

REPOSITORY
github.com/praetorian-inc/trajan
INSTALL
go install github.com/praetorian-inc/trajan/cmd/trajan@latest
Praetorian
← → / space · F fullscreen · N notes · C hide bar