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.
\nline 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.
| Key | Type | Nullable | Notes |
|---|---|---|---|
kind | string | no | Always the literal "discover-export". |
version | string | no | Always the literal "1" for this spec version. |
vendor | string | no | "google" or "entra" — a closed enum. |
generatedAt | string | no | ISO-8601 UTC timestamp, taken when the export script finished writing. |
pseudonymized | boolean | no | true unless the operator explicitly opted out. |
hashAlgo | string | no | Always the literal "hmac-sha256", even when pseudonymized is false — it documents which algorithm would be used. |
script | string | no | "nc-export.py" for the Google path, "nc-export-entra" for the Entra path (shared by both implementations of that path). |
exportWindow | object or null | yes | {"start": <ISO-8601>, "end": <ISO-8601>} when the source data has a known time bound, otherwise null. A script MUST NOT guess this. |
userCountTotal | integer or null | yes | Total 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. |
skippedRows | integer | no | Count 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.
| Key | Type | Nullable | Notes |
|---|---|---|---|
userRef | string | yes, only when consentType is "AllPrincipals" | A pseudonym (or, with pseudonymization off, the normalized email/UPN). |
consentType | string | no | "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. |
clientId | string | no | The 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). |
appDisplayName | string | no | Human-readable app name, taken verbatim from the source. |
scopes | array of strings | no | The 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). |
firstSeen | string or null | yes | ISO-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. |
lastUsed | string or null | yes | ISO-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 isnull. - A script MUST NOT silently fill
userCountTotalin from the grants seen — it is either an independently known total, or it isnull.
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
- Downloads and checksums — get the scripts and verify what you downloaded