Skip to main content

Discover Export Format (DEF)

DEF is the file format nc-export.py and nc-export-entra.ps1 produce and NobleCloak Discover consumes. The current version is DEF v1.

The authoritative specification lives in the noblecloak-tools repository, pinned to the release this page describes: spec/discover-export-format-v1.md. This page is a rendering of that spec for readability — if the two ever disagree, the tools repo is the source of truth.

Container​

  • UTF-8, no byte-order mark. \n line endings only, including the final line.
  • Newline-delimited JSON (NDJSON): line 1 is the header object; every line after that is one grant object. No trailing blank lines, no comments, no non-JSON lines.
  • Object keys are emitted in a fixed order (see the tables below), and non-ASCII characters are never escaped — an é in an app name is written as é. This is what makes the Python and PowerShell implementations byte-identical for the same input.

Header object​

The first line. All fields are required keys; some are nullable.

KeyTypeNullableNotes
kindstringnoAlways the literal "discover-export".
versionstringnoAlways the literal "1" for this spec version.
vendorstringno"google" or "entra" — a closed enum.
generatedAtstringnoISO-8601 UTC timestamp, taken when the export script finished writing.
pseudonymizedbooleannotrue unless the operator explicitly opted out.
hashAlgostringnoAlways the literal "hmac-sha256", even when pseudonymized is false — it documents which algorithm would be used.
scriptstringno"nc-export.py" for the Google path, "nc-export-entra" for the Entra path (shared by both implementations of that path).
exportWindowobject or nullyes{"start": <ISO-8601>, "end": <ISO-8601>} when the source data has a known time bound, otherwise null. A script MUST NOT guess this.
userCountTotalinteger or nullyesTotal distinct users in the source directory, when known. null when no independent count was available — normal, not an error, and never silently filled in from the grants seen.
skippedRowsintegernoCount of source rows dropped while building this export. Never null — always an integer, 0 when nothing was skipped. Does not count "AllPrincipals" grant lines (see below); those are emitted, not skipped.

Grant object​

Every line after the header. One (user, OAuth client) authorization relationship, already deduplicated — except "AllPrincipals" lines, which represent a tenant-wide admin consent grant with no associated user.

KeyTypeNullableNotes
userRefstringyes, only when consentType is "AllPrincipals"A pseudonym (or, with pseudonymization off, the normalized email/UPN).
consentTypestringno"Principal" (the ordinary per-user case) or "AllPrincipals" (a tenant-wide admin consent grant — Entra-only; Google grants are always "Principal"). Conceptually optional for a consumer to understand — an absent key can be read as "Principal" — but both current producers always emit it.
clientIdstringnoThe vendor's stable OAuth client identifier (Google: the OAuth client ID from the audit log; Entra: the service principal's appId, never its internal objectId).
appDisplayNamestringnoHuman-readable app name, taken verbatim from the source.
scopesarray of stringsnoThe union of every OAuth scope seen for this (user, clientId) pair. May be empty. An app-role assignment contributes a scope of the form "appRole:<role value>" (or "appRole:<appRoleId GUID>" when the role's display value can't be resolved).
firstSeenstring or nullyesISO-8601 timestamp of the earliest evidence of this grant, passed through verbatim from the source (including fractional-second precision) — never reformatted, rounded, or truncated. null when the source has no such timestamp.
lastUsedstring or nullyesISO-8601 timestamp of the most recent evidence of use, or null when unavailable.

App-role assignments (optional fourth input)​

Both scripts accept an optional fourth input — --app-role-assignments-json / -AppRoleAssignmentsJson — to additionally include Entra app-role assignment grants alongside oauth2PermissionGrants. Omit it and the feature is simply off; this input is entirely back-compatible.

An app-role assignment is mapped as: userRef from the assignment's principalId (the assigned user); clientId/appDisplayName from the resource service principal (resourceId, never the assignment's own id); scopes = a single ["appRole:<role value>"] entry, resolving appRoleId against the resource service principal's appRoles collection; firstSeen from createdDateTime; lastUsed = null. An app-role row for the same (user, clientId) pair as an OAuth2 permission grant is merged into that same grant line, scope union, not emitted as a separate line.

Coverage honesty​

Two rules the format is built around, both worth naming explicitly because they are what makes a partial export distinguishable from a complete one:

  • A script MUST NOT guess exportWindow — it is either known from the source data, or it is null.
  • A script MUST NOT silently fill userCountTotal in from the grants seen — it is either an independently known total, or it is null.

A report built from a file with both fields null is honestly presenting itself as coverage-unknown, not padding out a false completeness claim.

Next​