A multi-platform CI/CD vulnerability detection toolkit
GitHubGitLabAzure DevOps
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.
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-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