The format

A RAGE graph is a single NDJSON file. Line 1 is the manifest; every other line is a record tagged with kind. The normative text is below, rendered from the spec.

Record kinds

kindrequired fieldspurpose
manifest spec_version line 1: version + optional counts/scope/producer
node node_id node_type a resource or identity
edge source target type a capability or relationship
fact fact_id a normalized observation the graph was built from
evidence evidence_id content_hash tamper-evident receipt (an API/collection operation)
finding resource_id severity an exposure
surface resource_id an attack-surface observation (optional kind)
path edge_ids a precomputed attack path (optional kind)

The provenance chain runs evidence → fact → edge/finding, linked by ids.


RAGE Format (v0.1)

The bundle

A RAGE graph is one NDJSON file (*.rage.ndjson): one JSON object per line.

  • Line 1 MUST be the manifest.
  • Every other line is a record with a kind field.
  • Order is otherwise free. Unknown kinds MUST be ignored (forward-compatible).

Media type: application/vnd.rage.graph+ndjson. A producer SHOULD also self-declare it in the manifest via media_type so a bare file is identifiable without its extension.

.rage.zip (optional packaging). For upload-size limits or attaching raw blobs, a graph MAY be zipped. The zip layout is normative:

  • Exactly one *.rage.ndjson at the archive root is the graph (the canonical unit). A consumer reads that file and ignores the rest.
  • Optional blobs/ - raw API responses referenced by evidence.pointer (blobs/<hash>).
  • No other interpretation: a .rage.zip is a single graph plus its optional blobs, never multiple graphs.

Record kinds

manifest (line 1)

Required: spec_version. Optional: created_at, scope (providers, foothold node_id), producer (opaque version strings), counts (nodes/edges/findings - so consumers don't scan to count).

node

Required: node_id, node_type.

node_id grammar (normative). A node_id MUST be four |-delimited components:

<provider>|<account>|<native_type>|<native_id>
  • provider - aws | gcp | azure | a producer-defined lowercase token for other systems.
  • account - the identity/billing realm: AWS account id, GCP project id, Azure subscription id (or tenant id for tenant-scoped objects). This is the collision boundary, so sa@proj-asa@proj-b.
  • native_type - the provider's own resource type string (e.g. gcp:iam:service-account, aws:s3:bucket, Microsoft.Storage/storageAccounts).
  • native_id - the provider's stable identifier (ARN, resource id, self-link, or name).

Escaping. Any literal | inside a component MUST be percent-encoded as %7C (and %25 for a literal %). Consumers split on unescaped | only. This makes the id unambiguous and reversible.

Regional/global scope lives in the node's optional scope object (region/location), NOT in the node_id - a resource keeps one id across regional views. Cross-scope children (a subnet in a region) still use the account of their subscription/project as the realm.

Merging graphs from multiple producers. Because the realm (provider|account) and native_id are producer-independent, two producers that both follow this grammar emit identical node_ids for the same resource, so their graphs union by node_id without a reconciliation pass. Producers that cannot follow the grammar MUST still emit a globally-unique node_id and SHOULD set x-<provider> hints to aid matching.

  • node_type is the generic taxonomy type (ServiceAccount, ObjectStorage, …). It SHOULD come from vocab/node-types.json, but the vocab is open - unknown types are allowed (map to the nearest type + keep the native type verbatim). Optional: provider, name, scope, observed_at, attributes (free bag; provider extras under x-<provider>).

edge

Required: source, target, type. - source/target are node_ids. type is the capability (CanImpersonate, …). It SHOULD come from vocab/edge-types.json (open registry) - each type declares its legal source/target node types, walkable, and category. Recommended: state (default ACTIVE), nature (explicit|derived, default explicit - explicit = observed directly from configuration, derived = synthesized by a rule), edge_id, facts (array of fact_ids that justify this edge - the provenance link). - Conditional: if nature is derived, evidence is REQUIRED (an evidence_id, or an array of them). This is what keeps every derived edge explainable.

fact

A normalized observation the graph was built from - the middle of the provenance chain evidence (API) → fact → edge/finding. Required: fact_id. Recommended: edge_hint (the edge type this fact suggests), source/target (node refs), provider, scope, captured_at, content_hash, evidence (the evidence_id(s) - which API collection produced this fact), attributes.

finding (exposure)

Required: resource_id (the exposed node_id), severity (low|medium|high|critical). Optional: location, emits_hint, value_ref, evidence.

evidence

The tamper-evident receipt at the base of the chain - typically one API/collection operation. Required: evidence_id, content_hash (hash-addressable). Recommended: source (the API/operation, e.g. azure:role_assignment:list), status (collection outcome: ok/denied/…), captured_at, scope, pointer (where the raw response lives, if retained), detail (opaque bag).

surface (optional) / path (optional)

Extension kinds a producer MAY emit (attack surfaces; precomputed multi-hop paths). Not part of the required core; consumers that don't understand them skip them.

Rules

  • Required = the floor for any producer. A minimal graph is manifest + node + edge.
  • Optional = present when the producer did that analysis. Absence means "none," not "unknown." counts in the manifest says what's inside.
  • Extend without forking: new provider → x-<provider> attributes; unknown resource → nearest node_type + keep the native type verbatim; new kind → old readers skip it.

Scope

RAGE models an identity/access attack graph: who can reach and act on what, and how those capabilities chain. The following are out of scope for v0.1 - intentionally, to keep the core focused:

  • Vulnerabilities / exploits (CVEs). RAGE has no vulnerability node or exploit edge. A producer that also does vuln analysis MAY attach it via the open extension - e.g. finding.x-vuln = {"cve": "CVE-2026-1234", "cvss": 9.8} on the affected node's finding, or an x-<producer> edge - without forking the spec. First-class vuln modeling may be reconsidered in a later version.
  • Runtime/behavioral telemetry (process trees, netflow) - RAGE is a configuration/relationship graph, not an EDR.

finding records represent exposures (a credential or resource an attacker can reach), not software vulnerabilities.

The corpus (normative companions)

The wire format above says how to serialize a graph. The vocabulary that gives records meaning lives in these registries - RAGE is the source of truth for all of them:

  • vocab/node-types.json - the node taxonomy: 10 classes, 105 generic node_types (each a security role, not a product).
  • vocab/edge-types.json - the edge taxonomy: 80 types across 9 categories, with relationship_kind, allowed source/target, walkable, and high_value.
  • rules/derivation.json - how each edge is derived: nature (explicit/derived/both), conditions, and the concrete per-cloud permissions/triggers that realize it.
  • providers/{aws,gcp,azure}.json - two collection layers:
  • resources[] - the native→generic mappings + node recipes (enumerate operation, id/arn fields, detail-enrichment chain, required permissions) for every concrete resource type.
  • facts[] - the fact/relationship recipes: how to collect the policies, role assignments/bindings, trust policies, group memberships, network rules, and credential objects that back edges. Each fact recipe declares kind, collect (operation/over/scope), the edge types it backs, and required_permissions. Guardrail facts (SCPs, deny assignments) carry an empty backs - they constrain edges to BLOCKED rather than producing one.

Collection coverage (v0.1)

Node recipes alone yield inventory + structural edges. The facts[] recipes back the foundational walkable edges (HasPermission, HasRole, MemberOf, CanAssume, CrossAccountTrust, resource-policy and network edges, credential edges); the capability/derived edges (CanRead*, CanModify, CanExecuteAs, CanEscalateTo, CanEnter*, …) are composed from those foundations by the rules in rules/. Together they make all 70 walkable edge types producible - the last four (CanExecuteOn, CanReplace, CanCreateKey, AuthenticatesTo) are covered by the rules/derived/capability-completions.yaml roll-up/derivation rules.

Aspirational, not-yet-collected areas (documented limitations, not silent omissions): in-cluster Kubernetes (KubernetesWorkload, ContainerTask, pod-level Container - only the cluster object is collected) and external IdP identities (ExternalIdentity/FederatedIdentity from Okta/GitHub/Entra-as-IdP - the CSP side of FederatesTo is collected, the external side is not).

  • vocab/conditions.json - the (open) vocabulary for edge/rule conditions (the preconditions that gate a CONDITIONAL edge).
  • exposure-db/{aws,gcp,azure}.json + exposure-db/vocabulary.json - the Exposure DB: 1,049 exposure sites (every place a credential/secret can leak), each mapped to the RAGE edge it emits, its collection recipe (access_mode/operation/required_permissions), where it sits (location/location_kind), and what leaks (data_kinds).

Both registries are open: unknown types are legal (map to the nearest generic + keep the native string). ../TAXONOMY.md renders the whole corpus for humans; regenerate it from the registries with tools/gen_taxonomy.py. Validate the registries' internal integrity with validate/check_registries.py and the rule corpus with validate/check_rules.py.

Companion specs: - edges.md - edge semantics: direction/traversal, state, weight/walkable, and canonicalization (which of overlapping edges like CanAssume/CanImpersonate to emit). - rules.md - rule-evaluation semantics: the fixpoint, state-merge, and the normative function catalog (effective_action_on, node_type, …) two engines must agree on.


RAGE Edge Semantics (v0.1)

The wire format (format.md) says how to serialize an edge; this says what an edge means, how producers should choose between overlapping types, how a consumer traverses them, and what weight, walkable, state, and conditions are for. vocab/edge-types.json is the registry.

Direction & traversal

  • An edge is directed from the capable party (source) to the target of the capability: "source can do X to/as target." A path-finder traverses source → target - an attacker who controls source gains the capability over target.
  • walkable: true means the traversal is a real attacker move (identity/exec/credential/network/ data/cross-boundary/derived edges). walkable: false marks structural edges (Contains, LocatedIn, AttachedTo) - present for context and grouping, not for path-finding.
  • Exposure edges are the one direction exception. ExposedToInternet / ExposedToTenant/ExposedToAccount point resource → AnonymousIdentity/boundary to read naturally ("bucket is exposed to the internet"). For traversal they are entry-point seeds: the reachable principal (Internet/tenant/account) is the start, the resource is where the attacker arrives. A path-finder consumes ExposedTo* in reverse (target → source) to seed footholds, then follows normal edges forward. Producers MUST keep the resource → principal orientation so this convention holds.

state (traversability gate)

state meaning for the path-finder
ACTIVE all preconditions met - traverse it
CONDITIONAL traversable iff the listed conditions hold - traverse with that caveat
POTENTIAL the capability doesn't exist yet but source could create it - traverse only in "what-if" mode
BLOCKED a guardrail (SCP / deny assignment / explicit deny) prevents it - do not traverse
UNKNOWN insufficient collection to decide - traverse only if modeling worst-case

Default when omitted: ACTIVE.

weight & confidence (path scoring)

  • base_weight (on the edge type) and weight (on an instance) are a traversal cost - lower is cheaper / more likely for an attacker. It is a cost, not a probability. A path's score aggregates the weights of its edges; the cheapest path is the most attainable. Rough scale: ~1 = a direct, unconstrained capability; higher = more hops/conditions/effort.
  • confidence (0–1) is the producer's certainty the instance exists (e.g. a conditional allow lowers it). It is orthogonal to weight.

conditions

An edge's conditions list the preconditions that gate it (see state = CONDITIONAL). The token vocabulary is vocab/conditions.json (open - rule-specific tokens are allowed). A CONDITIONAL edge SHOULD list the conditions still unmet; an ACTIVE edge has none outstanding. In rules/derivation.json the per-edge conditions are the typical preconditions for that edge type; the authoritative, per-instance conditions come from the rules/ corpus that emitted the edge.

Canonicalization: which edge to emit

Several edge types overlap. Producers MUST emit the most specific edge that applies and MAY additionally emit a summary edge. Consumers SHOULD understand the subsumption below so a graph from one producer (specific edges) and another (summary edges) still compare.

Subsumption table

Summary / general edge Specific edges that imply it Rule of thumb
CanAssume (obtain another identity's session) CanImpersonate (SA impersonation / act-as), CanFederateAs (OIDC/SAML/WIF federation), CanRetrieveToken (metadata/token mint) emit the mechanism you observed; CanAssume is AWS role-assumption specifically and the umbrella concept
CanExecuteOn (get code running on a resource) CanExecuteCommand, CanInvoke, CanDeploy, CanModifyCode, CanModifyConfiguration, CanStart, CanTrigger, CanSchedule emit the specific execution primitive; CanExecuteOn is a computed superset
CanControl (effective control of a target) CanAdminister (full admin/owner), plus sufficient sub-capabilities (CanModifyPolicy, CanTakeOwnership, CanModify, …) CanAdminister = a single admin grant; CanControl = derived "admin OR enough pieces"; Controls = the terminal objective edge on a finished path
CanGrantPermission vs CanModifyPolicy - CanModifyPolicy = edit a policy document; CanGrantPermission = assign a role/permission. Emit the one matching the mechanism; both are privesc primitives
CanRead/CanWrite/CanModify (control-plane) vs CanReadData/CanWriteData (data-plane) - use the data edges for reads/writes of the stored data in a store; the generic ones for control-plane operations on the resource object
CanReadSecret vs CanReadCredential - CanReadSecret = read a secret-manager/KV secret; CanReadCredential = obtain a credential object (access key, SA key, token)
CanCreateCredentialFor CanCreateKey (service-account-key case) creating an SA key is minting a credential for that identity - emit CanCreateCredentialFor; CanCreateKey is the key-material view (also covers KMS CreateKey)

CanExecuteOn is a roll-up produced by rules/derived/capability-completions.yaml from any specific execution edge - producers emit the specific primitive; consumers may rely on the roll-up.

Derived vs foundational

nature: explicit edges come straight from configuration (a trust policy → CanAssume). nature: derived edges are composed by the rules/ corpus from other edges (CanModifyCode + ExecutesAsCanExecuteAs; a chain of capabilities → CanEscalateTo). A derived edge MUST carry evidence and SHOULD carry derived_from (the edge_ids it was built from) and rule_id. See rules.md.


RAGE Rule Evaluation (v0.1)

rules/{explicit,derived}/** is the RAGE Rules corpus; rules/derived/RULE-FORMAT.md defines a rule's shape (match → where → optional → emit). This document defines its evaluation semantics - enough that two independent engines run the same corpus over the same facts and produce the same graph. That is the reproducibility guarantee the format depends on.

Evaluation model

  1. Explicit pass. rules/explicit/** normalize raw collected config/facts into nature: explicit edges (a trust policy → CanAssume, a public bucket policy → ExposedToInternet).
  2. Fixpoint pass. rules/derived/** match a conjunctive pattern of existing edges/nodes and emit new edges. Repeat until no new edge is produced (a monotonic fixpoint).
  3. Dedup. An edge is keyed by (type, source, target, scope). Re-derivation with higher confidence / lower weight updates in place; the id is the deterministic edge_id (format.md).
  4. State merge. A derived edge is never stronger than its weakest contributor: a CONDITIONAL input yields at best CONDITIONAL; a BLOCKED contributor blocks the derivation entirely. conditions on the output are the union of unmet contributor conditions.
  5. Aggregation. confidence = min(contributing_confidences) * rule_prior; weight = sum(contributing_weights) + emits.base_weight; permissions = union(...).
  6. Explainability (mandatory). Every derived edge MUST carry evidence, and SHOULD carry derived_from (the contributor edge_ids) and rule_id.

Evaluation order within a pass MUST NOT affect the fixpoint result (rules are commutative up to the dedup/merge rules above) - this is what makes two engines agree.

Function catalog (normative)

where predicates and emit expressions may call only the following. An engine MUST implement them with these meanings; a rule using anything else is non-portable.

Node/graph accessors - node_type(?x) → the node's generic node_type. - node_class(?x) → the node's taxonomy class (vocab/node-types.json). - scope(?x) / account(?x) → the node's scope realm / account component of its node_id. - provider_type(?x) → the node's verbatim native type. - [ ?a, EdgeType, ?b ] (in match) → an edge of that type exists from ?a to ?b.

Authorization - effective_action_on(?principal, ?resource) → the set of actions ?principal may perform on ?resource after full policy evaluation: identity + resource policy union, minus explicit deny, minus SCP/org-policy deny, intersected with any permission boundary. This is the core primitive; engines MUST evaluate deny precedence identically (explicit deny and guardrail deny win). Companion: effective_action_on(...) intersects [<actions>]. - role(?assignment) / identity(?x) / principal(?x) → resolve an assignment's role / a node's identity / a policy statement's principal. - privilege_level(?identity) → an ordinal rank used only to decide whether a lateral capability is an escalation (priv(target) > priv(source)). The ranking is provider-defined but MUST be monotonic (admin > power-user > scoped > read-only); engines SHOULD document their ranking.

Predicates & aggregation - trigger_exists(?resource) → an invocation trigger/event source exists for the resource. - matched(optional[i]) → the i-th optional clause matched (drives state up/down). - intersects, in, ==, >= → set/relational tests over the above. - min, sum, union → aggregation over contributing edges (see step 5).

conditions

Unmet preconditions are attached to the emitted edge as conditions (from vocab/conditions.json, open). They gate state: an edge with outstanding conditions is CONDITIONAL, not ACTIVE. See edges.md for how a consumer treats each state during traversal.

Provider rules

Provider-specific derivations live under rules/derived/<provider>/ and MAY only match edges/nodes - they never mutate the taxonomy. They SHOULD emit common edge types and exist separately only when the derivation logic (not the concept) is provider-specific.

move · open · esc close