nhi-scan
Every workload, integration, and AI agent now has an identity — and nobody owns most of them.
nhi-scan inventories your non-human & agent identities, assigns each a defensible risk
tier, and maps its posture to the OWASP Non-Human Identities (NHI) Top 10
— with a least-privilege remediation for every finding.
It runs entirely locally and is deterministic: the same inventory always produces the same tiers and findings, so an assessment is explainable to an engineer and defensible to an auditor. No LLM is in the verdict path.
See PITCH.md for the why, with sourced market data — and A Control Framework for Non-Human & Agentic Identity for the control model this tool implements (30 controls mapped to OWASP NHI Top 10, NIST AI RMF, CSF 2.0, and 800-53).
Machine identities now outnumber human ones by more than 82:1 (CyberArk, 2025), and AI agents are the fastest-growing class — each one a privileged, often autonomous identity.
nhi-scantreats the agent as what it is: an NHI that needs an owner, least privilege, short-lived credentials, and a tier.
What's new (v0.3.0): owner validity (an identity whose owner has left is caught as orphaned, not "governed"), native staleness from sign-in activity, directory-role privilege, an Okta collector, a ~20× faster enriched Entra gather via Graph
$batch, and full Windows support. See the CHANGELOG for the full history since v0.1.
Quickstart (30 seconds)
pip install nhi-scan # from PyPI
# or from source: git clone https://github.com/rpmsft9/nhi-scan && cd nhi-scan && pip install -e .
nhi-scan scan examples/sample-inventory.json # full Markdown risk report
Sample output:
# Non-Human Identity Risk Report
**8** identities · **21** findings · **1** orphaned · **3** long-lived secrets
| Tier | Identities |
| ----------- | ---------: |
| 🔴 Critical | 3 |
| 🟠 High | 1 |
| 🟡 Moderate | 4 |
Point it at a JSON inventory (or generate one with a collector — see Collectors below) and you get a tiered, OWASP-NHI-mapped report with a remediation for every finding. New here? Start with the story and field notes at rajpenchala.com.
Why this exists
Discovery tools tell you how many secrets and service accounts you have. A CISO needs the next
two answers: which ones are crown jewels, and what do I fix first. nhi-scan is that
prioritization layer.
- Risk-tiering, not a flat list. A transparent floor-tier rules engine assigns Tier 1–4 from posture (privilege, credential type, rotation age, ownership, exposure, autonomy). The most severe rule that matches sets the tier, and every matching rule is recorded — so you can always answer "why is this Tier 1?"
- OWASP NHI Top 10 mapping. Ten posture checks emit findings tied to NHI1–NHI10, each with the
concrete evidence and a remediation. The regulatory content is a versioned data layer
(
owasp.py), not code. - Agent-aware. Autonomous AI agents with elevated privilege tier as crown jewels — the emerging gap that classic secret scanners don't model.
Prerequisites
nhi-scan is pure Python and runs on Linux, macOS, and Windows.
- Python ≥ 3.10 (
python --version). The core install has zero third-party dependencies. - pip (and, to run from source,
git). - Collectors are invoked as modules from the repository root (
python -m tools.collectors.*).
To generate an inventory from a live cloud, install and sign in to the matching read-only CLI. Hand-written, CSV, and MCP inventories need none of these.
| Source | CLI needed | macOS (Homebrew) | Windows (winget) |
|---|---|---|---|
| Entra ID / Entra Agent ID | Azure CLI (az) |
brew install azure-cli |
winget install Microsoft.AzureCLI |
| AWS IAM | AWS CLI v2 (aws) |
brew install awscli |
winget install Amazon.AWSCLI |
| GCP | Google Cloud SDK (gcloud) |
brew install --cask google-cloud-sdk |
winget install Google.CloudSDK |
| Merge step (optional) | jq |
brew install jq |
winget install jqlang.jq |
On Windows the cloud CLIs install as .cmd shims and their exports carry a UTF-8 BOM; both are
handled — the collectors locate the CLI on PATH on every OS and read JSON BOM-tolerantly.
Install
pip install nhi-scan # core, zero third-party dependencies (JSON inventories)
pip install "nhi-scan[yaml]" # + YAML inventory support
From source (for development / running the collectors):
git clone https://github.com/rpmsft9/nhi-scan && cd nhi-scan
pip install -e '.[dev]' # editable install + pytest
Required roles & permissions
Every collector is read-only — it reads identities and their posture, never writes and never touches a secret (auth stays with the CLI you signed in with). Grant the least-privilege role below for the source you're inventorying:
| Platform | Native collector | Least-privilege read role / permission |
|---|---|---|
| Entra ID (service principals) | gather_entra → entra |
Microsoft Graph application permission Application.Read.All (or Directory.Read.All). Directory role Global Reader is sufficient. |
| Entra Agent ID (AI agents) | gather_entra_agents → entra_agents |
AgentIdentity.Read.All + Application.Read.All (Directory.Read.All covers both). Directory role Global Reader. |
| AWS IAM | gather_aws → aws |
The AWS-managed IAMReadOnlyAccess policy. |
| GCP (service accounts) | gather_gcp → gcp |
roles/iam.securityReviewer (project Viewer also works). |
| Okta (service apps + API tokens) | gather_okta → okta |
A Read-Only Administrator SSWS token, or an OAuth token scoped okta.apps.read + okta.apiTokens.read. Provided via OKTA_ORG_URL / OKTA_API_TOKEN env vars (no CLI needed). |
| Ping and other IdPs | export → csv_import |
No native collector yet — export your apps / service accounts / tokens to CSV using a read-only administrator or reporting role (e.g. PingOne a read-only Identity Data Admin), then feed the CSV to csv_import. The role is only used for your export, not by nhi-scan. |
No role grants write access; nothing above can modify the directory or the identities it reads.
Which platforms are native? Entra ID, Entra Agent ID, AWS IAM, GCP, and Okta have first-class collectors. Everything else (Ping, GitHub, secrets managers, …) is inventoried by exporting a read-only report to CSV — see Preparing your inventory.
Usage
nhi-scan inventory examples/sample-inventory.json # counts by type and risk tier
nhi-scan scan examples/sample-inventory.json # full Markdown risk report
nhi-scan scan examples/sample-inventory.json --json # machine-readable JSON
nhi-scan diff examples/sample-inventory.json examples/sample-inventory-after.json # drift between two scans
See examples/sample-report.md for a full rendered report.
What a scan surfaces
8 identities · 21 findings · 1 orphaned · 3 long-lived secrets
🔴 Critical 3 🟠 High 1 🟡 Moderate 4 🟢 Baseline 0
The top-ranked identity in the sample is an autonomous collections AI agent holding
ledger:* in production — flagged Tier 1 (critical) for overprivilege and autonomy, exactly the
kind of NHI that never appears on a human-identity review.
The inventory format
A JSON or YAML list of NHI records. Only id and name are required; everything else defaults to
a safe, conservative posture, so a partial inventory still assesses.
{
"id": "agent-collections",
"name": "collections-ai-agent",
"type": "ai_agent",
"owner": "cx-automation@bank.example",
"environment": "prod",
"privilege": "privileged",
"credential": "static_secret",
"secret_storage": "env",
"last_rotated_days": 30,
"last_used_days": 0,
"exposure": "internal",
"scopes": ["accounts:read", "accounts:update", "ledger:*"],
"autonomous": true
}
| Field | Values | Drives |
|---|---|---|
type |
service_account, api_key, oauth_app, service_principal, managed_identity, workload_identity, ci_cd_token, pat, webhook, secret, ai_agent |
inventory + agent rules |
privilege |
admin, privileged, scoped, read_only |
tiering, NHI5 |
credential |
static_secret, api_key, certificate, federated, managed, short_lived_token, none |
NHI4, NHI7 |
secret_storage |
vault, env, plaintext, none |
NHI2 |
last_rotated_days / last_used_days |
integer or null | NHI7 / NHI1 (staleness) |
owner / owner_active |
string / bool | NHI1 — presence and validity; owner_active: false (a deprovisioned owner) is treated as orphaned |
exposure |
internet, external_partner, internal |
tiering, NHI6 |
scopes |
list of strings (* / :* = wildcard) |
NHI5 |
tools |
list of strings — an agent's tools / connectors / MCP servers | agent reach (drift) |
autonomous, third_party, human_used, shared_across_env, used_by |
booleans / list | agent rules, NHI3/8/9/10 |
Detecting drift — when an agent's reach grows
Privilege, credential age, and owner change only when someone touches the identity. An AI
agent's reach doesn't: give it a new tool, connector, or MCP server and its blast radius
grows while those attributes — and often the risk tier — look identical. nhi-scan diff
compares two inventories and surfaces exactly that:
nhi-scan diff before.json after.json # Markdown
nhi-scan diff before.json after.json --json # machine-readable
It reports added/removed identities, tier escalations, and — most importantly — a
"reach grew without a tier change" section: identities that gained tools or scopes while
privilege, credential age, and owner stayed the same. Run it on a schedule (or in CI) to catch
reach creep between point-in-time scans.
Where tools comes from. nhi-scan reads the agent's declared tool manifest — it doesn't
infer reach. Populate tools from wherever an agent's capabilities are defined: its MCP server
config (the tools each server exposes), its agent-framework manifest (LangChain / Semantic
Kernel / AutoGen function lists), or its registered plugins / connectors / actions in
platforms like Copilot Studio, Microsoft Agent 365 / Entra Agent ID. Feeding that manifest in
is what turns "an agent got a new connector" from an invisible change into a diffable one.
Preparing your inventory
Copy examples/template-inventory.json — it's an annotated
template with the allowed values inline and both a minimal and a fully-populated record. Then
build one record per identity by pulling from where your NHIs actually live:
| Source | What to pull |
|---|---|
| Entra / Okta / Ping | service principals, enterprise & OAuth apps, workload identities |
| AWS / Azure / GCP | IAM users & access keys, roles, service accounts, managed identities |
| Secrets managers / vaults | stored secrets and their rotation age |
| GitHub / GitLab / CI | PATs, deploy tokens, pipeline credentials |
| Agent platforms (e.g. Entra Agent ID) | your AI agents — set type: ai_agent and autonomous |
Start minimal, then enrich. Begin with the fields you can get cheaply — id, name, type,
owner, environment, privilege — run a scan, then add credential, last_rotated_days, and
scopes to sharpen the results. Missing fields fall back to conservative defaults rather than
failing, so a partial inventory still produces a useful report.
Generate the inventory automatically
For anything past a pilot, don't hand-write the file — generate it from your environment with
the collectors. Each is a read-only transform (source API JSON in →
nhi-scan JSON out) for Entra ID, Entra Agent ID, AWS IAM, GCP service accounts, Okta, and CSV exports — each paired with a read-only gather_* script that collects the identity's granted permissions (app roles and delegated scopes; attached, inline, and group-inherited IAM policies; project role bindings; Okta OAuth grants), so privilege is measured, not guessed:
python -m tools.collectors.gather_entra | python -m tools.collectors.entra > entra-nhi.json
python -m tools.collectors.gather_entra_agents | python -m tools.collectors.entra_agents > agents-nhi.json
python -m tools.collectors.gather_aws | python -m tools.collectors.aws > aws-nhi.json
python -m tools.collectors.gather_gcp | python -m tools.collectors.gcp > gcp-nhi.json
python -m tools.collectors.gather_okta | python -m tools.collectors.okta > okta-nhi.json
python -m tools.collectors.csv_import identities.csv > csv-nhi.json
# merge every source, then scan
jq -s 'add' *-nhi.json > inventory.json && nhi-scan scan inventory.json
Run the collect → merge → scan pipeline on a schedule to keep the inventory live. See the collectors guide for the read-only permissions and gather scripts.
Risk policy
Tiering rules live in nhiscan/tiering.py; OWASP checks in
nhiscan/checks.py. Both are ordered, pure-function lists — edit them to
match your control standard. Thresholds (rotation window, staleness window, wildcard scopes) are
constants at the top of nhiscan/models.py.
Tests
pytest -q
License
MIT.
Release files for nhi-scan 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| nhi_scan-0.3.0.tar.gz | 33.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nhi_scan-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 60.2 kB
Release files / nhi_scan-0.3.0.tar.gz
| Download URL | nhi_scan-0.3.0.tar.gz |
|---|---|
| Size | 33.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4c9925ecef55acfd748d927821eb4dee5640c23d4a6bef3a178b1c6d40b2ffa1
|
|
BLAKE2b-256 checksum How to use checksums |
c65bae44da642f016c40b28da6ffd2b0ab7c2523b58d739e49c293dfb6983398
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.5
|
Release files / nhi_scan-0.3.0-py3-none-any.whl
| Download URL | nhi_scan-0.3.0-py3-none-any.whl |
|---|---|
| Size | 26.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2da629d2e2df4b18fb76b2b8da20a9c977f0c93f412a261e579a4d480c2108f1
|
|
BLAKE2b-256 checksum How to use checksums |
0818d6a9e9e8236d1876645d1395dc8d2692c16500c719d9323367c8aa632103
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.5
|