petard

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 21, 2026 License: Apache-2.0

README

Petard

Tests Lint Security Go Reference

Attack-path analysis for policy-as-code. Petard reads Rego (OPA) policies, works out what their decisions depend on, and applies a curated taxonomy of privilege-escalation patterns to what it finds. The result leaves the tool as a structured BloodHound OpenGraph, where the escalations it found are edges the interface can walk.

A linter sees one file. Petard is built to see the policy, the data it reads and who can write that data, together, because that is where the interesting failures live: the ones that cross the line between whoever takes a decision and whoever writes what the decision is taken on.

The graph Petard exports, in BloodHound

What it finds

Eight patterns live in taxonomy-registry/, which is versioned data rather than code. Every one of them runs against the fixture in this repository, and the registry is the source of truth down to what each has been held to. These are one-line glosses.

ID What it looks for
PTD-OPA-001 the subject writes an attribute the policy reads to decide about them
PTD-OPA-002 a deny rule is silent for part of the data, so the check does not apply there
PTD-OPA-003 a position in a hierarchy grants everything below it, and nothing says so
PTD-OPA-004 a decision depends on an external source, so whoever controls it decides
PTD-OPA-005 a check that needs an external source stops applying when it does not answer
PTD-OPA-006 one decision lets a principal write the data another decision grants on
PTD-OPA-007 a check written with every stops applying when its collection is empty
PTD-OPA-008 a document every request shares decides for anybody who asks

Two of them end in the words privilege escalation, and each draws a PTD_CanEscalateTo edge between two principals: PTD-OPA-001 and PTD-OPA-003 chain into one, PTD-OPA-006 draws the other. Neither names a value it made up. The engine leaves the document unknown and asks OPA what is left of the decision, so "some value here works" is an answer from the policy rather than a guess about it. docs/taxonomy.md walks both chains.

Install

One file, no runtime:

# Linux, x86_64
curl -L -o petard https://github.com/saluc28/petard/releases/latest/download/petard_Linux_x86_64
chmod +x ./petard

# macOS, Apple silicon
curl -L -o petard https://github.com/saluc28/petard/releases/latest/download/petard_Darwin_arm64
chmod +x ./petard

On Windows:

Invoke-WebRequest -Uri "https://github.com/saluc28/petard/releases/latest/download/petard_Windows_x86_64.exe" -OutFile "petard.exe"

In a pipeline, ghcr.io/saluc28/petard:latest, or pin the version. From source, with Go 1.26 or newer, go install github.com/saluc28/petard/cmd/petard@latest.

petard version says which build it is and which OPA is compiled into it, which is what an analysis depends on.

Quickstart

Analyzing a policy never calls the endpoints written in it.

petard demo

demo analyzes a policy with escalation built into it on purpose, written by hand and carried inside the binary, so there is something to look at before you point the tool at your own policies. It reports what the decisions depend on, and then what the taxonomy found:

bundle:    8 files, parsed as rego v1
decisions: 13
request shape:  subject=input.user action=input.action resource=input.doc (level D, field names)
data paths read by the decisions: 11

PTD-OPA-006, One decision lets a principal write the data another decision grants on
  PTD-OPA-006 finding: carol can write editor into data.users[_].roles, which
  data.quill.admin.allow allows, and data.quill.publish.allow then grants the position
  alice holds, 2 places to look (confidence D), written via PUT /api/v1/users/{id}/roles

To send an analysis to BloodHound CE, write the bundle to disk and export it. The credentials come from two environment variables, because a token on a command line ends up in the shell history:

petard demo -extract .
export BLOODHOUND_TOKEN_ID=...
export BLOODHOUND_TOKEN_KEY=...
petard export -url https://bloodhound.example -install -upload -verify \
  -write-model vulnerable-bundle/write-model.yaml \
  -data vulnerable-bundle/data vulnerable-bundle/policy-v1
graph: 62 nodes, 84 edges
schema installed, 3 of 5 relationship kinds are traversable
saved queries: 29 added, 0 already there
ingest job 3 started
job 3 processed 1 file(s) with no errors
  MALLORY -> DAVE: pathfinding walks it
  CAROL -> ALICE: pathfinding walks it
2 of 2 escalations are walkable in BloodHound

-verify asks the server for a path between the two principals of every escalation in the payload, with only_traversable, and fails if it will not walk one. A graph that claims a path nobody can take is the single thing this tool must not produce.

Without an instance, -out graph.json writes the payload, and BloodHound takes a JSON upload from the interface.

Reading it in BloodHound

The escalations are edges, so they show up the way any other attack path does.

Two escalations in the graph

Select one and the Entity Panel says what was found on that edge: who takes whose position, in which decision, by writing what and through which endpoint, how many ways in the relation adds, at which level the request was recognized, and a link to the file of the pattern that reported it.

The Entity Panel of an escalation

Pathfinding walks them too, which is what -verify checks over the API.

Pathfinding between the two principals

Installing also saves 29 Cypher queries, two to four per pattern, named after the question they ask. They return nodes and paths, so the answer opens in the graph and in the table view next to it.

The saved queries, filtered

The model

Five node kinds and five relationship kinds, declared in internal/graph and exported through bhgraph.

graph LR
  Rule[PTD_Rule] -->|PTD_Reads| Attribute[PTD_Attribute]
  Attribute -->|PTD_WrittenBy| Principal[PTD_Principal]
  Principal -->|PTD_CanEscalateTo| Other[PTD_Principal]
  Principal -->|PTD_CanPerform| Resource[PTD_Resource]
  Principal -->|PTD_CanPerform| Action[PTD_Action]
  Attribute -->|PTD_TaintedBy| Source["PTD_Principal, an external source"]

PTD_CanPerform, PTD_WrittenBy and PTD_CanEscalateTo are the three BloodHound may walk. PTD_Reads and PTD_TaintedBy are dependencies rather than capabilities, and marking them traversable would invent paths in the interface that nobody can take. The decision is made in the model and the schema is generated from it, so the two cannot drift apart.

The kind names and the property keys are a contract before they are code. They end up in ingested data and in saved queries, so renaming one means re-ingesting every graph and rewriting every query that mentions it.

Running it on your own policy

The decisions are the starting points. A policy that annotates its entrypoints needs nothing; one that does not has to be told, because the alternative is guessing which rule the enforcement point queries:

petard analyze -entrypoint authz/allow path/to/policy

An enforcement point that refuses the request as soon as a rule returns anything, the way Gatekeeper reads violation, declares that side instead. Which side a read is on is then counted from the refusal:

petard analyze -deny-entrypoint k8sallowedrepos/violation path/to/policy

A decision can also be one field of what a rule returns, and the part of the request that names the requester can be declared rather than recognized:

petard analyze -entrypoint aac/aap/policy/owner_scope/allowed \
  -subject input.created_by.username -data path/to/config path/to/policy

Two inputs decide how much of an answer you get. Concrete data under -data is what turns a pattern into a finding about named principals, since the engine evaluates each decision partially against it. The write model under -write-model says who can write which path, which the policy never says, and without it every match stays a candidate. The run reports how many of the paths the decisions read the model covers, so an empty model is distinguishable from a clean result.

What it does not do

It does not decide whether a finding is a problem in your system. A position in a hierarchy is a target rather than a defect, and the report says who can take it and leaves the judgment to you.

It reports at the level the request was recognized at, from a declaration down to a guess about field names, and the level travels with every claim that rests on it. An edge measured at level D is a hypothesis about which field names the requester, and reads differently from one measured against a declaration.

Some shapes of negation are still invisible to the walk. count(S) == 0 over a comprehension written inline reads as an ordinary comparison, not count(S) > 0 is not read as "empty", and a policy that imports the future keywords for not, and and or reports no reads at all. The write model does not parse a quoted key that contains a dot or a slash.

Layout

cmd/petard            the binary, which dispatches to the subcommands
internal/cli          one package per subcommand: analyze, export, measure
internal/opaengine    everything that knows what Rego is
internal/graph        the engine-neutral model, all a second engine has to fill
internal/opengraph    the adapter to what BloodHound ingests, over bhgraph
internal/taxonomy     the registry reader and the patterns
internal/writemodel   who can write which path, declared rather than inferred
internal/fixture      generated worlds, and the truth about them
queries/              the saved Cypher queries, one file each
taxonomy-registry/    the patterns as versioned data
schema/               the extension definition schema, generated from the model
fixtures/             the bundle written by hand, in Rego v1 and v0, embedded for demo

Documentation

License

Apache-2.0. See LICENSE.

Directories

Path Synopsis
cmd
petard command
Command petard reads Rego policies, works out what their decisions depend on, applies the taxonomy of escalation patterns to what it finds, and sends the result to BloodHound as a structured OpenGraph.
Command petard reads Rego policies, works out what their decisions depend on, applies the taxonomy of escalation patterns to what it finds, and sends the result to BloodHound as a structured OpenGraph.
Package fixtures carries the hand-written bundle into the binary.
Package fixtures carries the hand-written bundle into the binary.
internal
cli/analyze
Command petard analyze reads Rego policies and reports what their decisions read from data, and who chooses the documents they land on.
Command petard analyze reads Rego policies and reports what their decisions read from data, and who chooses the documents they land on.
cli/demo
Package demo runs the analysis over the bundle built into the binary.
Package demo runs the analysis over the bundle built into the binary.
cli/export
Command petard export turns a policy into a BloodHound OpenGraph.
Command petard export turns a policy into a BloodHound OpenGraph.
cli/measure
Command petard measure runs the analysis over a body of Rego written by other people and reports what it could do with it.
Command petard measure runs the analysis over a body of Rego written by other people and reports what it could do with it.
fixture
Package fixture generates the data an analysis is measured against, with the facts about it written down.
Package fixture generates the data an analysis is measured against, with the facts about it written down.
graph
Package graph holds Petard's internal graph model: the one the taxonomy engine reasons over and the exporter serializes.
Package graph holds Petard's internal graph model: the one the taxonomy engine reasons over and the exporter serializes.
opaengine
Package opaengine wraps the OPA libraries.
Package opaengine wraps the OPA libraries.
opengraph
Package opengraph adapts Petard's internal model to what BloodHound ingests.
Package opengraph adapts Petard's internal model to what BloodHound ingests.
taxonomy
Package taxonomy applies the curated patterns of abuse to what the engine found.
Package taxonomy applies the curated patterns of abuse to what the engine found.
writemodel
Package writemodel reads the declaration of who can write what in the system around OPA.
Package writemodel reads the declaration of who can write what in the system around OPA.
Package queries holds the saved Cypher queries that come with Petard, one file per query, in the format of the BloodHound Query Library.
Package queries holds the saved Cypher queries that come with Petard, one file per query, in the format of the BloodHound Query Library.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL