mirror of
https://github.com/BishopFox/cirro.git
synced 2026-11-07 09:55:58 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0f7532aa | ||
|
|
229f16c11c | ||
|
|
e4dbdb8461 | ||
|
|
cb298b7751 | ||
|
|
6f190b187e | ||
|
|
ae93706be7 | ||
|
|
7ad8d94596 | ||
|
|
9300fc2d6f | ||
|
|
30242d25d6 | ||
|
|
b43fd05c53 | ||
|
|
e6337a796f |
No files matched your search
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.4.0] - 2026-11-05
|
||||
|
||||
### Added
|
||||
|
||||
- Restricted guest Microsoft Graph collection through service principal relationships with `--restricted-guest`.
|
||||
- Custom application ID seeds with `--restricted-appids` and an optional Microsoft application catalog with `--restricted-guest-msapps`.
|
||||
|
||||
## [1.3.0] - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Add user manager and password change datetime by @daddycocoaman in [#36](https://github.com/bishopfox/cirro/pull/36)
|
||||
- Add odata types and agent id relations by @daddycocoaman in [#35](https://github.com/bishopfox/cirro/pull/35)
|
||||
|
||||
### Changed
|
||||
|
||||
- Process createdByAppId by @daddycocoaman in [#34](https://github.com/bishopfox/cirro/pull/34)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use correct base64 decoding for tokens by @daddycocoaman in [#38](https://github.com/bishopfox/cirro/pull/38)
|
||||
- Check site serverfarmid for null by @daddycocoaman in [#37](https://github.com/bishopfox/cirro/pull/37)
|
||||
|
||||
## [1.2.1] - 2026-06-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix merges in loadbalancer by @daddycocoaman in [#33](https://github.com/bishopfox/cirro/pull/33)
|
||||
|
||||
## [1.2.0] - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -344,7 +344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cirro"
|
||||
version = "1.2.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
edition = "2024"
|
||||
name = "cirro"
|
||||
repository = "https://github.com/bishopfox/cirro"
|
||||
version = "1.2.0"
|
||||
version = "1.4.0"
|
||||
|
||||
[features]
|
||||
azure = ["collector"]
|
||||
|
||||
@@ -12,6 +12,10 @@ You can check out the [Documentation](https://bishopfox.github.io/cirro) for mor
|
||||
- **Cross-platform**: Available for Windows, macOS, and Linux
|
||||
- **Modular Design**: Optional platform functionality through feature flags and extensible plugin architecture
|
||||
- **Network Topology Analysis**: Support for network infrastructure platforms like Tailscale
|
||||
- **Entra ID Enrichment**: User managers and password-change timestamps, application creators, agent identity properties, and published permission scopes
|
||||
- **Restricted Guest Collection**: Traverse accessible application relationships with built-in, custom, or Microsoft catalog seeds
|
||||
|
||||
See the [collection guide](docs/usage/cirro.md) for usage examples and collection coverage.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -24,5 +24,6 @@ install-path = "CARGO_HOME"
|
||||
install-updater = false
|
||||
|
||||
[dist.github-custom-runners]
|
||||
aarch64-apple-darwin = "macos-15"
|
||||
aarch64-unknown-linux-gnu.container = "ghcr.io/rust-cross/cargo-zigbuild"
|
||||
aarch64-unknown-linux-musl.container = "ghcr.io/rust-cross/cargo-zigbuild"
|
||||
@@ -29,4 +29,7 @@ Represents Azure App Service web apps.
|
||||
- `inProgressOperationId` - In-progress operation ID
|
||||
|
||||
**Relationships:**
|
||||
- `HOSTS_SITE` ← ServerFarm
|
||||
|
||||
- `HOSTS_SITE` ← ServerFarm - Created only when `properties.serverFarmId` is non-null
|
||||
|
||||
Sites without a `serverFarmId` are still ingested, without a hosting ServerFarm relationship.
|
||||
@@ -0,0 +1,21 @@
|
||||
# CREATED
|
||||
|
||||
Links a creator application to an application or service principal using `createdByAppId`. The edge is created only when that property is present.
|
||||
|
||||
## Usage
|
||||
|
||||
- **GraphObject** → `CREATED` → **GraphApplication** - Creator matched by `appId`, stored in lowercase
|
||||
- **GraphApplication** → `CREATED` → **GraphServicePrincipal** - Creator matched by `appId`
|
||||
|
||||
A creator can initially be a placeholder with only an `appId`; its full details need not have been collected.
|
||||
|
||||
## Properties
|
||||
|
||||
No additional properties on the relationship.
|
||||
|
||||
## Examples
|
||||
|
||||
```cypher
|
||||
MATCH (creator)-[:CREATED]->(created)
|
||||
RETURN creator.appId, created.displayName, labels(created)
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# HAS_PUBLISHED_SCOPE
|
||||
|
||||
Links a service principal to a permission scope in its `publishedPermissionScopes` collection.
|
||||
|
||||
## Usage
|
||||
|
||||
- **GraphServicePrincipal** → `HAS_PUBLISHED_SCOPE` → **GraphApplicationScope** - Published permission scope
|
||||
|
||||
This relationship describes a published scope, not a consent grant or role assignment.
|
||||
|
||||
## Properties
|
||||
|
||||
No additional properties on the relationship.
|
||||
|
||||
## Examples
|
||||
|
||||
```cypher
|
||||
MATCH (sp:GraphServicePrincipal)-[:HAS_PUBLISHED_SCOPE]->(scope:GraphApplicationScope)
|
||||
RETURN sp.displayName, scope.value, scope.isEnabled
|
||||
```
|
||||
@@ -9,6 +9,7 @@ Represents management relationships between resources.
|
||||
**Common Patterns:**
|
||||
- Automation accounts manage runbooks
|
||||
- SQL servers manage databases
|
||||
- Entra ID managers manage enabled member users
|
||||
|
||||
**Properties:** None
|
||||
|
||||
@@ -26,4 +27,8 @@ RETURN path
|
||||
// Find management relationships
|
||||
MATCH path=(manager)-[:MANAGES]->(managed)
|
||||
RETURN path
|
||||
|
||||
// Find Entra ID users and their managers
|
||||
MATCH path=(manager:GraphUser)-[:MANAGES]->(user:GraphUser)
|
||||
RETURN path
|
||||
```
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
<h2>GraphObject (Base Label)</h2>
|
||||
|
||||
All Microsoft Graph entities inherit from the base `GraphObject` label and include the `id` property.
|
||||
Microsoft Graph directory entities use the base `GraphObject` label. Fully collected entities include an `id`; creator placeholders introduced by [`CREATED`](edges/created.md) can initially have only an `appId`.
|
||||
|
||||
**Common Properties:**
|
||||
|
||||
- `id` - Unique identifier (primary key)
|
||||
- `id` - Unique object identifier (primary key)
|
||||
- `odataType` - Lowercase `@odata.type`, when present, on users, groups, applications, service principals, devices, and administrative units
|
||||
@@ -6,6 +6,8 @@ Represents Entra ID administrative units collected from Microsoft Graph.
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `odataType` - Lowercase `@odata.type`, when returned by Microsoft Graph
|
||||
|
||||
- `description` - Administrative unit description
|
||||
- `displayName` - Administrative unit display name
|
||||
- `isMemberManagementRestricted` - Whether member management is restricted
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# GraphApplicationScope
|
||||
|
||||
Represents a permission scope from a service principal's `publishedPermissionScopes` collection.
|
||||
|
||||
**Labels:** `:GraphApplicationScope`
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `id` - Permission scope ID (merge key)
|
||||
- `adminConsentDescription` - Description shown for administrator consent
|
||||
- `adminConsentDisplayName` - Display name shown for administrator consent
|
||||
- `isEnabled` - Whether the scope is enabled
|
||||
- `type` - Consent type returned by Microsoft Graph
|
||||
- `userConsentDescription` - Description shown for user consent
|
||||
- `userConsentDisplayName` - Display name shown for user consent
|
||||
- `value` - Permission scope value
|
||||
|
||||
## Relationships
|
||||
|
||||
### Incoming
|
||||
|
||||
- **GraphServicePrincipal** → [`HAS_PUBLISHED_SCOPE`](../edges/has-published-scope.md) → **GraphApplicationScope** - Service principal publishing the scope
|
||||
|
||||
## Examples
|
||||
|
||||
```cypher
|
||||
MATCH (sp:GraphServicePrincipal)-[:HAS_PUBLISHED_SCOPE]->(scope:GraphApplicationScope)
|
||||
RETURN sp.displayName, scope.value, scope.type
|
||||
```
|
||||
@@ -6,6 +6,21 @@ Represents Entra ID applications collected from Microsoft Graph.
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `description` - Application description
|
||||
- `notes` - Application notes
|
||||
- `createdDateTime` - Creation timestamp
|
||||
- `createdByAppId` - Application ID of the creator
|
||||
- `applicationTemplateId` - Application template ID
|
||||
- `isAuthorizationServiceEnabled` - Authorization service flag
|
||||
- `isDeviceOnlyAuthSupported` - Device-only authentication support flag
|
||||
- `isDisabled` - Whether the application is disabled
|
||||
- `isFallbackPublicClient` - Fallback public client flag
|
||||
- `isManagementRestricted` - Management restriction flag
|
||||
- `nativeAuthenticationApisEnabled` - Native authentication API configuration
|
||||
- `defaultRedirectUri` - First URI in `web.redirectUris`
|
||||
|
||||
- `odataType` - Lowercase `@odata.type`, when returned by Microsoft Graph
|
||||
|
||||
- `id` - Application object ID (primary key)
|
||||
- `displayName` - Application's display name
|
||||
- `appId` - Application ID (client ID)
|
||||
@@ -23,15 +38,18 @@ Represents Entra ID applications collected from Microsoft Graph.
|
||||
|
||||
### Incoming
|
||||
|
||||
- **GraphObject** → [`CREATED`](../edges/created.md) → **GraphApplication** - Creator identified by `createdByAppId`
|
||||
|
||||
- **GraphObject** → `OWNS` → **GraphApplication** - Owners of the application
|
||||
- **GraphObject** → `APPROLE` → **GraphApplication** - Objects with app role assignments
|
||||
- **FederatedIdentityCredential** → `AUTHENTICATES` → **GraphApplication** - Federated identity credentials
|
||||
- **ClientSecret** → `AUTHENTICATES` → **GraphApplication** - Client secrets for authentication
|
||||
- **Certificate** → `AUTHENTICATES` → **GraphApplication** - Certificates for authentication
|
||||
|
||||
### Outgoing
|
||||
|
||||
- **GraphApplication** → [`CREATED`](../edges/created.md) → **GraphServicePrincipal** - Service principals created by the application
|
||||
|
||||
- **GraphApplication** → `HAS_APPROLE` → **GraphAppRole** - App roles defined by the application
|
||||
- **GraphApplication** → `FEDERATED_CREDENTIAL` → **FederatedIdentityCredential** - Federated identity credentials
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -50,6 +68,12 @@ RETURN app.displayName, collect(owner.displayName) AS owners
|
||||
|
||||
```cypher
|
||||
// Find applications with federated credentials
|
||||
MATCH (app:GraphApplication)-[:FEDERATED_CREDENTIAL]->(cred:FederatedIdentityCredential)
|
||||
MATCH (cred:FederatedIdentityCredential)-[:AUTHENTICATES]->(app:GraphApplication)
|
||||
RETURN app.displayName, cred.issuer, cred.subject
|
||||
```
|
||||
|
||||
```cypher
|
||||
// Find applications created by another application
|
||||
MATCH (creator:GraphObject)-[:CREATED]->(app:GraphApplication)
|
||||
RETURN creator.appId, app.displayName, app.createdDateTime, app.applicationTemplateId
|
||||
```
|
||||
@@ -6,6 +6,8 @@ Represents Entra ID devices collected from Microsoft Graph.
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `odataType` - Lowercase `@odata.type`, when returned by Microsoft Graph
|
||||
|
||||
- `displayName` - Device display name
|
||||
- `accountEnabled` - Whether the device account is enabled
|
||||
- `deviceId` - Device identifier
|
||||
|
||||
@@ -6,6 +6,8 @@ Represents Entra ID groups collected from Microsoft Graph.
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `odataType` - Lowercase `@odata.type`, when returned by Microsoft Graph
|
||||
|
||||
- `id` - Group object ID (primary key)
|
||||
- `displayName` - Group display name
|
||||
- `groupTypes` - Array of group types (e.g., ["DynamicMembership", "Unified"])
|
||||
|
||||
@@ -6,6 +6,13 @@ Represents Entra ID service principals collected from Microsoft Graph.
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `id` - Service principal object ID (primary key)
|
||||
- `createdByAppId` - Application ID of the creator
|
||||
- `agentAppId` - Agent application ID, when present
|
||||
- `agentIdentityBlueprintId` - Agent identity blueprint ID, when present
|
||||
|
||||
- `odataType` - Lowercase `@odata.type`, when returned by Microsoft Graph
|
||||
|
||||
- `displayName` - Service principal's display name
|
||||
- `accountEnabled` - Whether the service principal is enabled
|
||||
- `alternativeNames` - Alternative names for the service principal
|
||||
@@ -17,3 +24,38 @@ Represents Entra ID service principals collected from Microsoft Graph.
|
||||
- `logoutUrl` - Logout URL for the service principal
|
||||
- `replyUrls` - Array of reply URLs
|
||||
- `servicePrincipalNames` - Array of service principal names
|
||||
|
||||
## Relationships
|
||||
|
||||
### Incoming
|
||||
|
||||
- **GraphApplication** → [`CREATED`](../edges/created.md) → **GraphServicePrincipal** - Creator identified by `createdByAppId`
|
||||
- **GraphApplication** → [`REPRESENTED_BY`](../edges/represented-by.md) → **GraphServicePrincipal** - Application represented by the service principal, matched by `appId`
|
||||
- **GraphObject** → [`ASSIGNED_APPROLE`](../edges/assigned-approle.md) → **GraphServicePrincipal** - Principal assigned an app role on this resource
|
||||
- **GraphObject** → [`OWNS`](../edges/owns.md) → **GraphServicePrincipal** - Owner of the service principal
|
||||
|
||||
### Outgoing
|
||||
|
||||
- **GraphServicePrincipal** → [`HAS_PUBLISHED_SCOPE`](../edges/has-published-scope.md) → **GraphApplicationScope** - Scopes from `publishedPermissionScopes`
|
||||
- **GraphServicePrincipal** → [`HAS_APPROLE`](../edges/has-approle.md) → **GraphAppRole** - Roles defined by the service principal
|
||||
|
||||
## Examples
|
||||
|
||||
```cypher
|
||||
MATCH (sp:GraphServicePrincipal)-[:HAS_PUBLISHED_SCOPE]->(scope:GraphApplicationScope)
|
||||
RETURN sp.displayName, scope.value, scope.adminConsentDisplayName, scope.isEnabled
|
||||
```
|
||||
|
||||
```cypher
|
||||
// Review assignments, including principals with only an object ID
|
||||
MATCH (principal:GraphObject)-[assignment:ASSIGNED_APPROLE]->(resource:GraphServicePrincipal)
|
||||
RETURN coalesce(principal.displayName, principal.id) AS principal,
|
||||
resource.displayName, assignment.appRoleId
|
||||
```
|
||||
|
||||
```cypher
|
||||
// Find service principals with agent identity metadata
|
||||
MATCH (sp:GraphServicePrincipal)
|
||||
WHERE sp.agentAppId IS NOT NULL OR sp.agentIdentityBlueprintId IS NOT NULL
|
||||
RETURN sp.displayName, sp.odataType, sp.agentAppId, sp.agentIdentityBlueprintId
|
||||
```
|
||||
@@ -6,6 +6,12 @@ Represents Entra ID users collected from Microsoft Graph.
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `agentIdentityBlueprintId` - Agent identity blueprint ID, when present
|
||||
- `identityParentId` - Parent identity ID, when present
|
||||
- `identityParentType` - Parent identity type, when present
|
||||
|
||||
- `odataType` - Lowercase `@odata.type`, when returned by Microsoft Graph
|
||||
|
||||
- `id` - User object ID (primary key)
|
||||
- `accountEnabled` - Whether the user account is enabled
|
||||
- `city` - User's city
|
||||
@@ -16,6 +22,7 @@ Represents Entra ID users collected from Microsoft Graph.
|
||||
- `displayName` - User's display name
|
||||
- `givenName` - User's first name
|
||||
- `jobTitle` - User's job title
|
||||
- `lastPasswordChangeDateTime` - When the user's password was last changed or created
|
||||
- `mail` - User's email address
|
||||
- `mailNickname` - User's mail nickname
|
||||
- `mobilePhone` - User's mobile phone number
|
||||
@@ -37,6 +44,10 @@ Represents Entra ID users collected from Microsoft Graph.
|
||||
|
||||
## Relationships
|
||||
|
||||
### Incoming
|
||||
|
||||
- **GraphUser** ← `MANAGES` ← **GraphObject** - The user's manager
|
||||
|
||||
### Outgoing
|
||||
|
||||
- **GraphUser** → `MEMBER_OF` → **GraphObject** - Groups the user is a member of
|
||||
@@ -62,3 +73,26 @@ RETURN u.displayName, u.mail, u.companyName
|
||||
MATCH (u:GraphUser)-[:MEMBER_OF]->(g:GraphGroup)
|
||||
RETURN u.displayName, collect(g.displayName) AS groups
|
||||
```
|
||||
|
||||
```cypher
|
||||
// Find enabled member users and their managers
|
||||
MATCH (manager:GraphObject)-[:MANAGES]->(user:GraphUser)
|
||||
WHERE user.accountEnabled = true AND user.userType = "Member"
|
||||
RETURN user.displayName, coalesce(manager.displayName, manager.id) AS manager
|
||||
```
|
||||
|
||||
```cypher
|
||||
// Review collected password-change timestamps
|
||||
MATCH (user:GraphUser)
|
||||
WHERE user.lastPasswordChangeDateTime IS NOT NULL
|
||||
RETURN user.userPrincipalName, user.lastPasswordChangeDateTime
|
||||
ORDER BY user.lastPasswordChangeDateTime
|
||||
```
|
||||
|
||||
```cypher
|
||||
// Find users with agent identity metadata
|
||||
MATCH (user:GraphUser)
|
||||
WHERE user.agentIdentityBlueprintId IS NOT NULL OR user.identityParentId IS NOT NULL
|
||||
RETURN user.displayName, user.odataType, user.agentIdentityBlueprintId,
|
||||
user.identityParentId, user.identityParentType
|
||||
```
|
||||
+4
-1
@@ -30,6 +30,8 @@ Get up and running with Cirro in minutes. Install, collect data, ingest into Neo
|
||||
|
||||
- :material-cloud-outline: **Multi-source Inputs**: Collect from supported cloud, identity, and network APIs
|
||||
- :material-account-group: **Relationship Data**: Capture principals, resources, memberships, and trust links
|
||||
- :material-account-search: **User Enrichment**: Collect user managers and password-change timestamps
|
||||
- :material-account-key: **Restricted Guest Collection**: Discover accessible application relationships from selected application IDs
|
||||
- :material-puzzle: **Extensible Modules**: Add new collectors through feature-based platform modules
|
||||
|
||||
=== "Authentication"
|
||||
@@ -47,6 +49,7 @@ Get up and running with Cirro in minutes. Install, collect data, ingest into Neo
|
||||
|
||||
- :simple-neo4j: **Neo4j Ingestion**: Load collected data into a graph database for analysis
|
||||
- :material-graph-outline: **Schema Mapping**: Transform platform data into consistent nodes and edges
|
||||
- :material-application: **Application Context**: Analyze creator relationships, agent identity properties, and published permission scopes
|
||||
- :material-export-variant: **Export Support**: Export graph data for downstream tooling
|
||||
|
||||
=== "Schema Extensibility"
|
||||
@@ -84,4 +87,4 @@ Strengthen platform security posture by analyzing:
|
||||
- Configuration hardening
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,6 +67,15 @@ cirro graph ingest --type az --file cirro_output.db --dry-run
|
||||
cirro graph ingest --type ts --file cirro_ts_socket.json --dry-run
|
||||
```
|
||||
|
||||
### Restricted guest results
|
||||
|
||||
Ingest restricted guest output with the normal Azure ingestion command:
|
||||
|
||||
```bash
|
||||
cirro graph ingest --type az --file cirro_restricted.db --dry-run
|
||||
cirro graph ingest --type az --file cirro_restricted.db
|
||||
```
|
||||
|
||||
### Dry-Run Mode
|
||||
|
||||
The `--dry-run` flag lets you preview an ingestion without writing any data to the graph database.
|
||||
@@ -133,4 +142,4 @@ docker-compose up -d
|
||||
This starts Neo4j with:
|
||||
- Web UI at `http://localhost:7477`
|
||||
- Bolt protocol at `bolt://localhost:7688`
|
||||
- Default credentials: `neo4j/password`
|
||||
- Default credentials: `neo4j/password`
|
||||
+35
-1
@@ -46,7 +46,41 @@ cirro collect ts <auth-method> --output-path cirro_ts_socket.json
|
||||
cirro collect az azcli --debug
|
||||
```
|
||||
|
||||
## Restricted Guest Collection
|
||||
|
||||
Use `--restricted-guest` to collect Microsoft Graph data as a restricted Entra ID guest. These options are available with all Azure authentication commands:
|
||||
|
||||
| Option | Usage |
|
||||
| --- | --- |
|
||||
| `--restricted-guest` | Enable restricted guest collection using the default application IDs. |
|
||||
| `--restricted-appids <GUID> ...` | Replace the default application/client IDs. Accepts comma-separated, space-separated or repeated values. |
|
||||
| `--restricted-guest-msapps` | Use the Microsoft application catalog. Prompts for confirmation before collection. |
|
||||
|
||||
`--restricted-appids` and `--restricted-guest-msapps` both require `--restricted-guest` and cannot be combined.
|
||||
|
||||
```bash
|
||||
# Use the default application IDs
|
||||
cirro collect az azcli --tenant-id <tenant-id> --mode graph --restricted-guest \
|
||||
--output-path cirro_restricted.db
|
||||
|
||||
# Use an existing Microsoft Graph token
|
||||
cirro collect az access-token --token <GRAPH_ACCESS_TOKEN> --restricted-guest \
|
||||
--output-path cirro_restricted.db
|
||||
|
||||
# Use a specific application ID
|
||||
cirro collect az azcli --tenant-id <tenant-id> --mode graph --restricted-guest \
|
||||
--restricted-appids <APP_ID>
|
||||
|
||||
# Use the Microsoft application catalog
|
||||
cirro collect az azcli --tenant-id <tenant-id> --mode graph \
|
||||
--restricted-guest --restricted-guest-msapps
|
||||
```
|
||||
|
||||
Use `--mode graph` for Graph-only collection or `--mode both` to include ARM. Restricted guest options are ignored in `--mode arm`. With `access-token`, supply a Microsoft Graph token and omit `--mode`.
|
||||
|
||||
Normal Graph selection flags such as `--users`, `--no-default`, `--caps` and `--graph-pim` do not apply in restricted guest mode. Ingest the output using the normal [Azure ingestion command](cirro-graph.md#restricted-guest-results).
|
||||
|
||||
## Notes
|
||||
|
||||
- Ensure you have appropriate permissions for your target environment.
|
||||
- Debug logging can include sensitive values; handle output carefully.
|
||||
- Debug logging can include sensitive values; handle output carefully.
|
||||
@@ -41,6 +41,15 @@ cirro collect az client-secret \
|
||||
--tenant-id <TENANT_ID>
|
||||
```
|
||||
|
||||
### Collect as a restricted guest
|
||||
|
||||
```bash
|
||||
cirro collect az azcli --tenant-id <TENANT_ID> --mode graph \
|
||||
--restricted-guest --output-path cirro_restricted.db
|
||||
```
|
||||
|
||||
See [restricted guest options](cirro.md#restricted-guest-collection) to specify application IDs or use the Microsoft application catalog. Ingest `cirro_restricted.db` with the command below, replacing the input filename.
|
||||
|
||||
## Ingest into Graph Database
|
||||
|
||||
Neo4j is supported as the graph database backend. Set up your database before ingesting cloud data. There is a docker-compose file in the [tools](https://github.com/bishopfox/cirro/tools) directory to assist with containerized database setup.
|
||||
@@ -63,6 +72,6 @@ cirro graph ingest --type az --file cirro_output.db \
|
||||
After completing the quick start:
|
||||
|
||||
1. **Explore Your Data**: Use your graph database's query interface to explore the collected data
|
||||
2. **Learn Query Patterns**: Check out our [dashboard examples](../analysis/dashboard.md) for common analysis patterns
|
||||
2. **Learn Query Patterns**: Explore [user and manager queries](../analysis/graph-nodes/graph-user.md#examples) and [service principal relationships](../analysis/graph-nodes/graph-service-principal.md#examples)
|
||||
3. **Set Up Visualization**: Configure dashboards and visualizations for your specific use case
|
||||
4. **Advanced Features**: Explore data enrichment and custom collection options
|
||||
4. **Advanced Features**: Explore [collection options](cirro.md) and [application metadata](../analysis/graph-nodes/graph-application.md)
|
||||
@@ -88,6 +88,30 @@ pub struct AccessTokenAuthArgs {
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct OptionEnumFlags {
|
||||
/// Replace normal Graph enumeration with restricted-guest application-role traversal
|
||||
#[arg(long = "restricted-guest", action = clap::ArgAction::SetTrue)]
|
||||
pub restricted_guest: bool,
|
||||
|
||||
/// Override restricted-guest application IDs (space/comma-separated or repeated)
|
||||
#[arg(
|
||||
long = "restricted-appids",
|
||||
value_name = "GUID",
|
||||
value_delimiter = ',',
|
||||
num_args = 1..,
|
||||
value_parser = validate_uuid,
|
||||
requires = "restricted_guest"
|
||||
)]
|
||||
pub restricted_appids: Vec<String>,
|
||||
|
||||
/// Enumerate the full Microsoft application catalog (warning: large enumeration!)
|
||||
#[arg(
|
||||
long = "restricted-guest-msapps",
|
||||
action = clap::ArgAction::SetTrue,
|
||||
requires = "restricted_guest",
|
||||
conflicts_with = "restricted_appids"
|
||||
)]
|
||||
pub restricted_guest_msapps: bool,
|
||||
|
||||
/// Skip all default graph enumerations; only run explicitly enabled options (e.g. --pim, --caps)
|
||||
#[arg(long = "no-default", action = clap::ArgAction::SetTrue)]
|
||||
pub no_default: bool,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::collect::azure::cli::{AzureCloud, EnumerationMode, OptionEnumFlags};
|
||||
use crate::collect::azure::collectors::arm;
|
||||
use crate::collect::azure::collectors::graph;
|
||||
use crate::collect::azure::collectors::restricted_guest;
|
||||
use crate::collect::azure::context::CloudEndpoints;
|
||||
use crate::collect::azure::context::CollectorContext;
|
||||
use crate::collect::azure::credentials::azcli::AzureCliCredential;
|
||||
@@ -10,7 +11,8 @@ use crate::errors::CirroError;
|
||||
|
||||
use futures;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::{self, Write};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -34,8 +36,37 @@ impl DerefMut for Collector {
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_restricted_guest_msapps() -> Result<bool, CirroError> {
|
||||
let application_count = restricted_guest::microsoft_application_count();
|
||||
warn!(
|
||||
"--restricted-guest-msapps will enumerate the full catalog of {} Microsoft applications and may generate many Graph requests",
|
||||
application_count
|
||||
);
|
||||
print!("Continue with Microsoft application enumeration? [y/N]: ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut response = String::new();
|
||||
io::stdin().read_line(&mut response)?;
|
||||
|
||||
Ok(matches!(
|
||||
response.trim().to_ascii_lowercase().as_str(),
|
||||
"y" | "yes"
|
||||
))
|
||||
}
|
||||
|
||||
/// Runs the collector, performing the actual data collection
|
||||
async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(), CirroError> {
|
||||
let mut seen_restricted_appids = HashSet::new();
|
||||
collector.option_enum_flags.restricted_appids =
|
||||
std::mem::take(&mut collector.option_enum_flags.restricted_appids)
|
||||
.into_iter()
|
||||
.map(|app_id| app_id.to_ascii_lowercase())
|
||||
.filter(|app_id| seen_restricted_appids.insert(app_id.clone()))
|
||||
.collect();
|
||||
|
||||
let restricted_graph_enabled = collector.option_enum_flags.restricted_guest
|
||||
&& (collector.mode == EnumerationMode::Graph || collector.mode == EnumerationMode::Both);
|
||||
|
||||
info!("Using cloud: {:?}", collector.cloud);
|
||||
info!("Using mode: {:?}", collector.mode);
|
||||
info!(
|
||||
@@ -43,6 +74,22 @@ async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(),
|
||||
collector.output_path.to_path_buf().as_path()
|
||||
);
|
||||
|
||||
if collector.mode == EnumerationMode::Arm {
|
||||
if collector.option_enum_flags.restricted_guest_msapps {
|
||||
warn!("--restricted-guest-msapps is ignored because ARM-only mode is active");
|
||||
} else if collector.option_enum_flags.restricted_guest {
|
||||
info!("Restricted-guest application IDs are unused because ARM-only mode is active");
|
||||
}
|
||||
}
|
||||
|
||||
if restricted_graph_enabled
|
||||
&& collector.option_enum_flags.restricted_guest_msapps
|
||||
&& !confirm_restricted_guest_msapps()?
|
||||
{
|
||||
info!("Microsoft application enumeration cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create the database writer with bounded channel for backpressure
|
||||
const DB_CHANNEL_CAPACITY: usize = 16_384;
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<DBWriteMessage>(DB_CHANNEL_CAPACITY);
|
||||
@@ -59,7 +106,7 @@ async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(),
|
||||
let (graph_state, arm_state) = {
|
||||
let db = SqliteDb::new(collector.output_path.clone());
|
||||
if new_state {
|
||||
info!("--new-state: clearing saved enumeration state");
|
||||
info!("Clearing saved enumeration state");
|
||||
db.clear_state(None)
|
||||
.map_err(|e| CirroError::DatabaseError(format!("Failed to clear state: {}", e)))?;
|
||||
(HashMap::new(), HashMap::new())
|
||||
@@ -72,7 +119,7 @@ async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(),
|
||||
.filter(|(k, _)| !k.starts_with("arm:"))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
if !graph.is_empty() {
|
||||
if !graph.is_empty() && !restricted_graph_enabled {
|
||||
info!(
|
||||
"Resuming graph enumeration for {} resource type(s)",
|
||||
graph.len()
|
||||
@@ -83,6 +130,11 @@ async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(),
|
||||
rt, count, elapsed
|
||||
);
|
||||
}
|
||||
} else if !graph.is_empty() && restricted_graph_enabled {
|
||||
debug!(
|
||||
"Restricted-guest Graph enumeration starts fresh; ignoring {} normal Graph state record(s)",
|
||||
graph.len()
|
||||
);
|
||||
}
|
||||
let arm: HashMap<_, _> = all_state
|
||||
.into_iter()
|
||||
@@ -130,12 +182,19 @@ async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(),
|
||||
// Create a task for Graph enumeration
|
||||
let graph_collector_clone = Arc::clone(&graph_collector);
|
||||
let graph_task = tokio::spawn(async move {
|
||||
debug!("Starting Graph API enumeration task");
|
||||
if let Err(e) = graph::enumerate_graph(graph_collector_clone, graph_state).await
|
||||
{
|
||||
let result = if graph_collector_clone.option_enum_flags.restricted_guest {
|
||||
debug!("Starting restricted-guest Graph API enumeration task");
|
||||
restricted_guest::enumerate_restricted_guest(graph_collector_clone).await
|
||||
} else {
|
||||
debug!("Starting Graph API enumeration task");
|
||||
graph::enumerate_graph(graph_collector_clone, graph_state).await
|
||||
};
|
||||
|
||||
if let Err(e) = &result {
|
||||
error!("Error collecting Graph data: {}", e);
|
||||
}
|
||||
debug!("Graph API enumeration task completed");
|
||||
result
|
||||
});
|
||||
|
||||
tasks.push(graph_task);
|
||||
@@ -162,10 +221,12 @@ async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(),
|
||||
let arm_collector_clone = Arc::clone(&arm_collector);
|
||||
let arm_task = tokio::spawn(async move {
|
||||
debug!("Starting ARM API enumeration task");
|
||||
if let Err(e) = arm::enumerate_arm(arm_collector_clone, arm_state).await {
|
||||
let result = arm::enumerate_arm(arm_collector_clone, arm_state).await;
|
||||
if let Err(e) = &result {
|
||||
error!("Error collecting ARM data: {}", e);
|
||||
}
|
||||
debug!("ARM API enumeration task completed");
|
||||
result
|
||||
});
|
||||
|
||||
tasks.push(arm_task);
|
||||
@@ -179,10 +240,21 @@ async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(),
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
// Wait for all tasks to complete, but keep the DB writer alive until every
|
||||
// collector has flushed its messages.
|
||||
let mut collection_errors = Vec::new();
|
||||
if !tasks.is_empty() {
|
||||
debug!("Waiting for {} enumeration tasks to complete", tasks.len());
|
||||
futures::future::join_all(tasks).await;
|
||||
for result in futures::future::join_all(tasks).await {
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => collection_errors.push(error),
|
||||
Err(error) => collection_errors.push(CirroError::ProcessingError(format!(
|
||||
"Enumeration task failed: {}",
|
||||
error
|
||||
))),
|
||||
}
|
||||
}
|
||||
info!("All enumeration tasks completed");
|
||||
}
|
||||
|
||||
@@ -198,7 +270,11 @@ async fn run_collector(mut collector: Collector, new_state: bool) -> Result<(),
|
||||
.map_err(|e| CirroError::DatabaseError(e.to_string()))?;
|
||||
|
||||
info!("Done");
|
||||
Ok(())
|
||||
match collection_errors.len() {
|
||||
0 => Ok(()),
|
||||
1 => Err(collection_errors.remove(0)),
|
||||
_ => Err(CirroError::MultipleErrors(collection_errors)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects data using Access Token credentials
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,5 @@
|
||||
pub mod arm;
|
||||
pub mod common;
|
||||
pub mod graph;
|
||||
pub mod restricted_guest;
|
||||
mod restricted_guest_apps;
|
||||
@@ -0,0 +1,616 @@
|
||||
use super::graph::{GraphPager, graph_get_response};
|
||||
use super::restricted_guest_apps::{application_name, microsoft_apps, well_known_apps};
|
||||
use crate::collect::azure::collect::Collector;
|
||||
use crate::errors::CirroError;
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Default)]
|
||||
struct CrawlStats {
|
||||
seeds_found: usize,
|
||||
seeds_missing: usize,
|
||||
seeds_blocked: usize,
|
||||
applications: usize,
|
||||
oauth_grants: usize,
|
||||
assignments: usize,
|
||||
relationship_failures: usize,
|
||||
unresolved_service_principals: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CrawlState {
|
||||
service_principals: HashMap<String, Value>,
|
||||
app_id_to_sp_id: HashMap<String, String>,
|
||||
neighbors: HashMap<String, Vec<String>>,
|
||||
unavailable_service_principals: HashSet<String>,
|
||||
probed_application_ids: HashSet<String>,
|
||||
written_grant_ids: HashSet<String>,
|
||||
pending_assignments: HashMap<String, Vec<Value>>,
|
||||
stats: CrawlStats,
|
||||
}
|
||||
|
||||
enum OptionalResponse {
|
||||
Found(Value),
|
||||
Forbidden,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
struct CollectionResponse {
|
||||
values: Vec<Value>,
|
||||
incomplete: bool,
|
||||
}
|
||||
|
||||
/// Enumerates service principals and applications through relationship surfaces
|
||||
/// that remain readable to a restricted Entra ID guest.
|
||||
pub async fn enumerate_restricted_guest(collector: Arc<Collector>) -> Result<(), CirroError> {
|
||||
let seed_apps = restricted_seed_apps(&collector);
|
||||
let mut state = CrawlState::default();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
info!(
|
||||
"Starting restricted-guest Graph enumeration with {} application ID seed(s)",
|
||||
seed_apps.len()
|
||||
);
|
||||
|
||||
for (seed_app_id, display_name) in seed_apps {
|
||||
info!("Enumerating {}: {}", seed_app_id, display_name);
|
||||
|
||||
let Some(root_id) =
|
||||
resolve_seed_service_principal(&collector, &seed_app_id, &mut state).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut queue = VecDeque::from([root_id]);
|
||||
|
||||
while let Some(service_principal_id) = queue.pop_front() {
|
||||
if !visited.insert(service_principal_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let neighbors =
|
||||
process_service_principal(&collector, &service_principal_id, &mut state).await?;
|
||||
|
||||
for neighbor_id in neighbors {
|
||||
if !visited.contains(&neighbor_id) {
|
||||
queue.push_back(neighbor_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Restricted-guest Graph enumeration completed: {} seed(s) found, {} missing, {} blocked; {} service principal(s), {} application(s), {} OAuth grant(s), {} app-role assignment(s), {} incomplete relationship(s), {} unresolved service principal(s)",
|
||||
state.stats.seeds_found,
|
||||
state.stats.seeds_missing,
|
||||
state.stats.seeds_blocked,
|
||||
state.service_principals.len(),
|
||||
state.stats.applications,
|
||||
state.stats.oauth_grants,
|
||||
state.stats.assignments,
|
||||
state.stats.relationship_failures,
|
||||
state.stats.unresolved_service_principals,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn microsoft_application_count() -> usize {
|
||||
microsoft_apps().len()
|
||||
}
|
||||
|
||||
fn restricted_seed_apps(collector: &Collector) -> Vec<(String, String)> {
|
||||
let configured = &collector.option_enum_flags.restricted_appids;
|
||||
if !configured.is_empty() {
|
||||
let mut seen = HashSet::new();
|
||||
return configured
|
||||
.iter()
|
||||
.map(|app_id| app_id.to_ascii_lowercase())
|
||||
.filter(|app_id| seen.insert(app_id.clone()))
|
||||
.map(|app_id| {
|
||||
let display_name = application_name(&app_id)
|
||||
.unwrap_or("Unknown application")
|
||||
.to_string();
|
||||
(app_id, display_name)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if collector.option_enum_flags.restricted_guest_msapps {
|
||||
return microsoft_apps()
|
||||
.iter()
|
||||
.map(|(app_id, catalog_name)| {
|
||||
let display_name = application_name(app_id).unwrap_or(catalog_name).to_string();
|
||||
(app_id.clone(), display_name)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
well_known_apps()
|
||||
.iter()
|
||||
.map(|(app_id, display_name)| ((*app_id).to_string(), (*display_name).to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn resolve_seed_service_principal(
|
||||
collector: &Collector,
|
||||
app_id: &str,
|
||||
state: &mut CrawlState,
|
||||
) -> Result<Option<String>, CirroError> {
|
||||
if let Some(service_principal_id) = state.app_id_to_sp_id.get(app_id) {
|
||||
state.stats.seeds_found += 1;
|
||||
return Ok(Some(service_principal_id.clone()));
|
||||
}
|
||||
|
||||
let uri = format!("servicePrincipals(appId='{}')", app_id);
|
||||
match get_optional_object(collector, &uri).await? {
|
||||
OptionalResponse::Found(service_principal) => {
|
||||
let service_principal_id = register_service_principal(state, service_principal)?;
|
||||
state.stats.seeds_found += 1;
|
||||
debug!(
|
||||
"Resolved restricted-guest seed app ID {} to service principal {}",
|
||||
app_id, service_principal_id
|
||||
);
|
||||
Ok(Some(service_principal_id))
|
||||
}
|
||||
OptionalResponse::Forbidden => {
|
||||
state.stats.seeds_blocked += 1;
|
||||
warn!(
|
||||
"Authorization prevented alternate-key lookup for application ID {}; presence is indeterminate",
|
||||
app_id
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
OptionalResponse::NotFound => {
|
||||
state.stats.seeds_missing += 1;
|
||||
debug!(
|
||||
"No service principal was found for application ID {}",
|
||||
app_id
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_service_principal(
|
||||
collector: &Collector,
|
||||
service_principal_id: &str,
|
||||
state: &mut CrawlState,
|
||||
) -> Result<Vec<String>, CirroError> {
|
||||
if let Some(neighbors) = state.neighbors.get(service_principal_id) {
|
||||
return Ok(neighbors.clone());
|
||||
}
|
||||
if state
|
||||
.unavailable_service_principals
|
||||
.contains(service_principal_id)
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if !state.service_principals.contains_key(service_principal_id) {
|
||||
let uri = format!("servicePrincipals/{}", service_principal_id);
|
||||
match get_optional_object(collector, &uri).await? {
|
||||
OptionalResponse::Found(service_principal) => {
|
||||
register_service_principal(state, service_principal)?;
|
||||
}
|
||||
OptionalResponse::Forbidden | OptionalResponse::NotFound => {
|
||||
state
|
||||
.unavailable_service_principals
|
||||
.insert(service_principal_id.to_string());
|
||||
state.stats.unresolved_service_principals += 1;
|
||||
warn!(
|
||||
"Could not resolve discovered service principal {}; skipping its relationships",
|
||||
service_principal_id
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app_id = state
|
||||
.service_principals
|
||||
.get(service_principal_id)
|
||||
.and_then(|value| value.get("appId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase);
|
||||
|
||||
if let Some(app_id) = app_id {
|
||||
probe_application(collector, &app_id, state).await?;
|
||||
}
|
||||
|
||||
let inbound =
|
||||
fetch_sp_relationship(collector, service_principal_id, "appRoleAssignedTo").await?;
|
||||
let outbound =
|
||||
fetch_sp_relationship(collector, service_principal_id, "appRoleAssignments").await?;
|
||||
let oauth_grants =
|
||||
fetch_sp_relationship(collector, service_principal_id, "oauth2PermissionGrants").await?;
|
||||
let owners = fetch_sp_relationship(collector, service_principal_id, "owners").await?;
|
||||
|
||||
state.stats.relationship_failures += usize::from(inbound.incomplete)
|
||||
+ usize::from(outbound.incomplete)
|
||||
+ usize::from(oauth_grants.incomplete)
|
||||
+ usize::from(owners.incomplete);
|
||||
|
||||
let mut neighbors = Vec::new();
|
||||
let mut seen_neighbors = HashSet::new();
|
||||
let mut inbound_assignments = Vec::new();
|
||||
|
||||
for assignment in &inbound.values {
|
||||
if let Some(normalized) = normalize_assignment(assignment, None) {
|
||||
push_unique_assignment(&mut inbound_assignments, normalized);
|
||||
state.stats.assignments += 1;
|
||||
}
|
||||
|
||||
let is_service_principal = assignment
|
||||
.get("principalType")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("ServicePrincipal"));
|
||||
if is_service_principal
|
||||
&& let Some(principal_id) = assignment
|
||||
.get("principalId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
{
|
||||
push_unique_neighbor(&mut neighbors, &mut seen_neighbors, principal_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut cached_targets_to_rewrite = HashSet::new();
|
||||
for assignment in &outbound.values {
|
||||
let Some(resource_id) = assignment
|
||||
.get("resourceId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
push_unique_neighbor(&mut neighbors, &mut seen_neighbors, resource_id.clone());
|
||||
|
||||
if let Some(normalized) = normalize_assignment(assignment, Some(service_principal_id)) {
|
||||
let pending = state
|
||||
.pending_assignments
|
||||
.entry(resource_id.clone())
|
||||
.or_default();
|
||||
push_unique_assignment(pending, normalized);
|
||||
state.stats.assignments += 1;
|
||||
if state.service_principals.contains_key(&resource_id) {
|
||||
cached_targets_to_rewrite.insert(resource_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut grant_rows = Vec::new();
|
||||
for grant in oauth_grants.values {
|
||||
if let Some(resource_id) = grant
|
||||
.get("resourceId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
{
|
||||
push_unique_neighbor(&mut neighbors, &mut seen_neighbors, resource_id);
|
||||
}
|
||||
|
||||
let Some(grant_id) = grant.get("id").and_then(Value::as_str) else {
|
||||
warn!(
|
||||
"Skipping OAuth2 permission grant without an ID on service principal {}",
|
||||
service_principal_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let grant_id = grant_id.to_ascii_lowercase();
|
||||
if state.written_grant_ids.insert(grant_id.clone()) {
|
||||
grant_rows.push((grant_id, grant));
|
||||
}
|
||||
}
|
||||
if !grant_rows.is_empty() {
|
||||
state.stats.oauth_grants += grant_rows.len();
|
||||
collector
|
||||
.write_values_batch_to_db("oauth2PermissionGrants".to_string(), grant_rows)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut owner_ids = Vec::new();
|
||||
let mut seen_owner_ids = HashSet::new();
|
||||
for owner in owners.values {
|
||||
let Some(owner_id) = owner
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if seen_owner_ids.insert(owner_id.clone()) {
|
||||
owner_ids.push(owner_id.clone());
|
||||
}
|
||||
|
||||
let is_service_principal = owner
|
||||
.get("@odata.type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "#microsoft.graph.servicePrincipal");
|
||||
if is_service_principal {
|
||||
push_unique_neighbor(&mut neighbors, &mut seen_neighbors, owner_id);
|
||||
}
|
||||
}
|
||||
|
||||
let pending_for_current = state
|
||||
.pending_assignments
|
||||
.get(service_principal_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let current_snapshot = {
|
||||
let service_principal = state
|
||||
.service_principals
|
||||
.get_mut(service_principal_id)
|
||||
.ok_or_else(|| {
|
||||
CirroError::InvalidData(format!(
|
||||
"Missing cached service principal {}",
|
||||
service_principal_id
|
||||
))
|
||||
})?;
|
||||
let service_principal_object = service_principal.as_object_mut().ok_or_else(|| {
|
||||
CirroError::InvalidData("Cached Graph object was not a JSON object".to_string())
|
||||
})?;
|
||||
service_principal_object.insert(
|
||||
"owners".to_string(),
|
||||
Value::Array(owner_ids.iter().cloned().map(Value::String).collect()),
|
||||
);
|
||||
service_principal_object.insert("appRoleAssignedTo".to_string(), Value::Array(Vec::new()));
|
||||
merge_assignment_property(service_principal, &inbound_assignments)?;
|
||||
merge_assignment_property(service_principal, &pending_for_current)?;
|
||||
service_principal.clone()
|
||||
};
|
||||
collector
|
||||
.write_value_to_db(
|
||||
"servicePrincipals".to_string(),
|
||||
service_principal_id.to_string(),
|
||||
current_snapshot,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for target_id in cached_targets_to_rewrite {
|
||||
let pending = state
|
||||
.pending_assignments
|
||||
.get(&target_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let Some(target) = state.service_principals.get_mut(&target_id) else {
|
||||
continue;
|
||||
};
|
||||
merge_assignment_property(target, &pending)?;
|
||||
let target_snapshot = target.clone();
|
||||
collector
|
||||
.write_value_to_db("servicePrincipals".to_string(), target_id, target_snapshot)
|
||||
.await?;
|
||||
}
|
||||
|
||||
state
|
||||
.neighbors
|
||||
.insert(service_principal_id.to_string(), neighbors.clone());
|
||||
|
||||
Ok(neighbors)
|
||||
}
|
||||
|
||||
async fn probe_application(
|
||||
collector: &Collector,
|
||||
app_id: &str,
|
||||
state: &mut CrawlState,
|
||||
) -> Result<(), CirroError> {
|
||||
if !state.probed_application_ids.insert(app_id.to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let uri = format!("applications(appId='{}')", app_id);
|
||||
match get_optional_object(collector, &uri).await? {
|
||||
OptionalResponse::Found(application) => {
|
||||
let application_id = required_id(&application, "application")?;
|
||||
collector
|
||||
.write_value_to_db("applications".to_string(), application_id, application)
|
||||
.await?;
|
||||
state.stats.applications += 1;
|
||||
}
|
||||
OptionalResponse::Forbidden => {
|
||||
debug!(
|
||||
"Backing application lookup for app ID {} was forbidden",
|
||||
app_id
|
||||
);
|
||||
}
|
||||
OptionalResponse::NotFound => {
|
||||
debug!("No local application exists for app ID {}", app_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_optional_object(
|
||||
collector: &Collector,
|
||||
uri: &str,
|
||||
) -> Result<OptionalResponse, CirroError> {
|
||||
let response = graph_get_response(collector, uri).await?;
|
||||
if response.status == StatusCode::FORBIDDEN {
|
||||
return Ok(OptionalResponse::Forbidden);
|
||||
}
|
||||
if response.status == StatusCode::NOT_FOUND {
|
||||
return Ok(OptionalResponse::NotFound);
|
||||
}
|
||||
|
||||
let body = response.into_success()?;
|
||||
if !body.is_object() {
|
||||
return Err(CirroError::InvalidData(format!(
|
||||
"Graph response for {} was not an object",
|
||||
uri
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(OptionalResponse::Found(body))
|
||||
}
|
||||
|
||||
async fn fetch_sp_relationship(
|
||||
collector: &Collector,
|
||||
service_principal_id: &str,
|
||||
relationship: &str,
|
||||
) -> Result<CollectionResponse, CirroError> {
|
||||
fetch_collection(
|
||||
collector,
|
||||
&format!(
|
||||
"servicePrincipals/{}/{}?$top=999",
|
||||
service_principal_id, relationship
|
||||
),
|
||||
service_principal_id,
|
||||
relationship,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_collection(
|
||||
collector: &Collector,
|
||||
initial_uri: &str,
|
||||
service_principal_id: &str,
|
||||
relationship: &str,
|
||||
) -> Result<CollectionResponse, CirroError> {
|
||||
let mut values = Vec::new();
|
||||
let mut incomplete = false;
|
||||
let mut pager = GraphPager::new(initial_uri);
|
||||
|
||||
while let Some(response) = pager.next_page(collector).await? {
|
||||
let status = response.status;
|
||||
if status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND {
|
||||
debug!(
|
||||
"Relationship request for {}/{} returned HTTP {}; keeping {} collected item(s)",
|
||||
service_principal_id,
|
||||
relationship,
|
||||
status.as_u16(),
|
||||
values.len()
|
||||
);
|
||||
incomplete = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let body = response.into_success()?;
|
||||
let page_values = body.get("value").and_then(Value::as_array).ok_or_else(|| {
|
||||
CirroError::InvalidData(format!(
|
||||
"Graph collection response for {}/{} did not contain a value array",
|
||||
service_principal_id, relationship
|
||||
))
|
||||
})?;
|
||||
values.extend(page_values.iter().cloned());
|
||||
}
|
||||
|
||||
Ok(CollectionResponse { values, incomplete })
|
||||
}
|
||||
|
||||
fn register_service_principal(
|
||||
state: &mut CrawlState,
|
||||
service_principal: Value,
|
||||
) -> Result<String, CirroError> {
|
||||
let service_principal_id = required_id(&service_principal, "service principal")?;
|
||||
if let Some(app_id) = service_principal
|
||||
.get("appId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
{
|
||||
state
|
||||
.app_id_to_sp_id
|
||||
.insert(app_id, service_principal_id.clone());
|
||||
}
|
||||
state
|
||||
.service_principals
|
||||
.entry(service_principal_id.clone())
|
||||
.or_insert(service_principal);
|
||||
Ok(service_principal_id)
|
||||
}
|
||||
|
||||
fn required_id(value: &Value, object_type: &str) -> Result<String, CirroError> {
|
||||
value
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.ok_or_else(|| {
|
||||
CirroError::InvalidData(format!(
|
||||
"Graph {} response did not contain an ID",
|
||||
object_type
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_assignment(assignment: &Value, fallback_principal_id: Option<&str>) -> Option<Value> {
|
||||
let principal_id = assignment
|
||||
.get("principalId")
|
||||
.and_then(Value::as_str)
|
||||
.or(fallback_principal_id)?
|
||||
.to_ascii_lowercase();
|
||||
let app_role_id = assignment
|
||||
.get("appRoleId")
|
||||
.and_then(Value::as_str)?
|
||||
.to_ascii_lowercase();
|
||||
|
||||
Some(json!({
|
||||
"principalId": principal_id,
|
||||
"appRoleId": app_role_id,
|
||||
}))
|
||||
}
|
||||
|
||||
fn merge_assignment_property(object: &mut Value, additions: &[Value]) -> Result<(), CirroError> {
|
||||
if additions.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let object = object.as_object_mut().ok_or_else(|| {
|
||||
CirroError::InvalidData("Cached Graph object was not a JSON object".to_string())
|
||||
})?;
|
||||
let assignments = object
|
||||
.entry("appRoleAssignedTo")
|
||||
.or_insert_with(|| Value::Array(Vec::new()))
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| {
|
||||
CirroError::InvalidData(
|
||||
"Cached Graph object appRoleAssignedTo property was not an array".to_string(),
|
||||
)
|
||||
})?;
|
||||
for addition in additions {
|
||||
push_unique_assignment(assignments, addition.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_unique_assignment(assignments: &mut Vec<Value>, assignment: Value) {
|
||||
let key = assignment_key(&assignment);
|
||||
if assignments
|
||||
.iter()
|
||||
.all(|existing| assignment_key(existing) != key)
|
||||
{
|
||||
assignments.push(assignment);
|
||||
}
|
||||
}
|
||||
|
||||
fn assignment_key(assignment: &Value) -> String {
|
||||
let principal_id = assignment
|
||||
.get("principalId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let app_role_id = assignment
|
||||
.get("appRoleId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if principal_id.is_empty() && app_role_id.is_empty() {
|
||||
assignment.to_string()
|
||||
} else {
|
||||
format!("{}:{}", principal_id, app_role_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique_neighbor(
|
||||
neighbors: &mut Vec<String>,
|
||||
seen: &mut HashSet<String>,
|
||||
neighbor_id: String,
|
||||
) {
|
||||
if seen.insert(neighbor_id.clone()) {
|
||||
neighbors.push(neighbor_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MICROSOFT_GRAPH_APP_COUNT: usize = 1_753;
|
||||
|
||||
/*
|
||||
* Publicly documented, well-known Microsoft application IDs used as the
|
||||
* default starting points for restricted-guest enumeration. Keep the
|
||||
* display-name comments beside each ID so this map remains easy to review.
|
||||
*/
|
||||
static WELL_KNOWN_APP_IDS: Lazy<BTreeMap<&'static str, &'static str>> = Lazy::new(|| {
|
||||
BTreeMap::from([
|
||||
// Windows Azure Active Directory
|
||||
(
|
||||
"00000002-0000-0000-c000-000000000000",
|
||||
"Windows Azure Active Directory",
|
||||
),
|
||||
// Microsoft Graph
|
||||
("00000003-0000-0000-c000-000000000000", "Microsoft Graph"),
|
||||
// Office 365 Exchange Online
|
||||
(
|
||||
"00000002-0000-0ff1-ce00-000000000000",
|
||||
"Office 365 Exchange Online",
|
||||
),
|
||||
// Office 365 SharePoint Online
|
||||
(
|
||||
"00000003-0000-0ff1-ce00-000000000000",
|
||||
"Office 365 SharePoint Online",
|
||||
),
|
||||
// Skype for Business Online
|
||||
(
|
||||
"00000004-0000-0ff1-ce00-000000000000",
|
||||
"Skype for Business Online",
|
||||
),
|
||||
// Dataverse
|
||||
("00000007-0000-0000-c000-000000000000", "Dataverse"),
|
||||
// Power BI Service
|
||||
("00000009-0000-0000-c000-000000000000", "Power BI Service"),
|
||||
// Microsoft Intune
|
||||
("0000000a-0000-0000-c000-000000000000", "Microsoft Intune"),
|
||||
// Microsoft Azure CLI
|
||||
(
|
||||
"04b07795-8ddb-461a-bbee-02f9e1bf7b46",
|
||||
"Microsoft Azure CLI",
|
||||
),
|
||||
// Microsoft Azure PowerShell
|
||||
(
|
||||
"1950a258-227b-4e31-a9cf-717495945fc2",
|
||||
"Microsoft Azure PowerShell",
|
||||
),
|
||||
// Microsoft Teams
|
||||
("1fec8e78-bce4-4aaf-ab1b-5451cc387264", "Microsoft Teams"),
|
||||
// Azure DevOps
|
||||
("499b84ac-1321-427f-aa17-267ca6975798", "Azure DevOps"),
|
||||
// Windows Azure Service Management API
|
||||
(
|
||||
"797f4846-ba00-4fd7-ba43-dac1f8f63013",
|
||||
"Windows Azure Service Management API",
|
||||
),
|
||||
// Azure Portal
|
||||
("c44b4083-3bb0-49c1-b47d-974e53cbdf3c", "Azure Portal"),
|
||||
// Azure Key Vault
|
||||
("cfa8b339-82a2-471a-a3c9-0fc0be7a4093", "Azure Key Vault"),
|
||||
// Microsoft Office
|
||||
("d3590ed6-52b3-4102-aeff-aad2292ab01c", "Microsoft Office"),
|
||||
// Microsoft Intune Enrollment
|
||||
(
|
||||
"d4ebce55-015a-49b5-a083-c84d1797ae8c",
|
||||
"Microsoft Intune Enrollment",
|
||||
),
|
||||
// Azure Storage
|
||||
("e406a681-f3d4-42a8-90b6-c2b029497af1", "Azure Storage"),
|
||||
])
|
||||
});
|
||||
|
||||
/// Microsoft application resource catalog from:
|
||||
/// https://github.com/f-bader/entrascopes.com/blob/b0ccd972055ee7f78b446a5d75e14e7fe4c58ede/resources.json
|
||||
///
|
||||
/// Source SHA-256:
|
||||
/// a20ea8fadb3668f86c1e352697b6b456c5928684c5e820543b5d5cc59ea82bb7
|
||||
static MICROSOFT_GRAPH_APP_IDS: Lazy<BTreeMap<String, String>> = Lazy::new(|| {
|
||||
let resources: Vec<MicrosoftGraphResource> =
|
||||
serde_json::from_str(include_str!("restricted_guest_msapps.json"))
|
||||
.expect("embedded Microsoft application catalog must be valid JSON");
|
||||
let resource_count = resources.len();
|
||||
assert_eq!(
|
||||
resource_count, MICROSOFT_GRAPH_APP_COUNT,
|
||||
"embedded Microsoft application catalog is incomplete"
|
||||
);
|
||||
let applications: BTreeMap<_, _> = resources
|
||||
.into_iter()
|
||||
.map(|resource| {
|
||||
let app_id = Uuid::parse_str(&resource.resource_id)
|
||||
.expect("embedded Microsoft application ID must be a valid UUID")
|
||||
.hyphenated()
|
||||
.to_string();
|
||||
let display_name = resource.display_name.trim();
|
||||
assert!(
|
||||
!display_name.is_empty(),
|
||||
"embedded Microsoft application name must not be empty"
|
||||
);
|
||||
(app_id, display_name.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
applications.len(),
|
||||
resource_count,
|
||||
"embedded Microsoft application catalog contains duplicate IDs"
|
||||
);
|
||||
applications
|
||||
});
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MicrosoftGraphResource {
|
||||
resource_id: String,
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
pub(super) fn well_known_apps() -> &'static BTreeMap<&'static str, &'static str> {
|
||||
&WELL_KNOWN_APP_IDS
|
||||
}
|
||||
|
||||
pub(super) fn microsoft_apps() -> &'static BTreeMap<String, String> {
|
||||
&MICROSOFT_GRAPH_APP_IDS
|
||||
}
|
||||
|
||||
pub(super) fn application_name(app_id: &str) -> Option<&'static str> {
|
||||
WELL_KNOWN_APP_IDS
|
||||
.get(app_id)
|
||||
.copied()
|
||||
.or_else(|| MICROSOFT_GRAPH_APP_IDS.get(app_id).map(String::as_str))
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -71,10 +71,9 @@ impl Token {
|
||||
return Err(CirroError::AuthError("Invalid JWT token format".into()));
|
||||
}
|
||||
|
||||
let claims_json = base64::engine::general_purpose::STANDARD_NO_PAD
|
||||
let claims_json = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(token_parts[1])
|
||||
.map_err(|e| CirroError::AuthError(format!("Failed to decode token: {}", e)))?;
|
||||
|
||||
let claims: Claims = serde_json::from_slice(&claims_json)?;
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ cypher: |
|
||||
WITH fe, frontend
|
||||
CALL (fe, frontend) {
|
||||
WITH fe, frontend WHERE frontend.properties.publicIPAddress.id IS NOT NULL
|
||||
MERGE (pip:{{ LABELS.PublicIPAddress }} {id: toLower(frontend.properties.publicIPAddress.id)})
|
||||
MERGE (pip:{{ LABELS.ArmResource }} {id: toLower(frontend.properties.publicIPAddress.id)})
|
||||
SET pip:{{ LABELS.PublicIPAddress }}
|
||||
MERGE (fe)-[: {{ REL.HAS_IP }}]->(pip)
|
||||
}
|
||||
}
|
||||
@@ -76,7 +77,8 @@ cypher: |
|
||||
WITH be, backend
|
||||
CALL {
|
||||
WITH be, backend UNWIND backend.properties.backendIPConfigurations AS nic
|
||||
MERGE (n:{{ LABELS.NetworkInterface }} {id: toLower(nic.id)})
|
||||
MERGE (n:{{ LABELS.ArmResource }} {id: toLower(nic.id)})
|
||||
SET n:{{ LABELS.NetworkInterface }}
|
||||
MERGE (be)-[: {{ REL.ASSOCIATED_WITH }}]->(n)
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,17 @@ cypher: |
|
||||
sku: row.properties.siteConfig.sku
|
||||
}
|
||||
|
||||
MERGE (sf:{{ LABELS.ArmResource }} {id: toLower(row.properties.serverFarmId)})
|
||||
SET sf:{{ LABELS.ServerFarm }}
|
||||
MERGE (sf)-[:HOSTS_SITE]->(obj)
|
||||
|
||||
WITH obj, row
|
||||
|
||||
CALL (obj, row) {
|
||||
// Link to ServerFarm. Need to perform a null check since ContainerApps don't have a serverFarmId
|
||||
WITH obj, row
|
||||
WHERE row.properties.serverFarmId IS NOT NULL
|
||||
MERGE (sf:{{ LABELS.ArmResource }} {id: toLower(row.properties.serverFarmId)})
|
||||
SET sf:{{ LABELS.ServerFarm }}
|
||||
MERGE (sf)-[:HOSTS_SITE]->(obj)
|
||||
}
|
||||
|
||||
// Link to Web Certificates
|
||||
CALL (obj, row) {
|
||||
UNWIND coalesce(row.properties.hostNameSslStates, []) AS sslState
|
||||
|
||||
@@ -2,6 +2,7 @@ name: "Administrative Units"
|
||||
label: "{{ LABELS.GraphAdministrativeUnit }}"
|
||||
table_name: "administrativeUnits"
|
||||
properties:
|
||||
- "/@odata.type"
|
||||
- "/id"
|
||||
- "/description"
|
||||
- "/displayName"
|
||||
@@ -17,6 +18,7 @@ cypher: |
|
||||
MERGE (obj:{{ LABELS.GraphObject }} {id: toLower(row.id)})
|
||||
SET obj:{{ LABELS.GraphAdministrativeUnit }}
|
||||
SET obj += {
|
||||
odataType: lower(row["@odata.type"]),
|
||||
description: row.description,
|
||||
displayName: row.displayName,
|
||||
isMemberManagementRestricted: row.isMemberManagementRestricted,
|
||||
|
||||
@@ -2,14 +2,27 @@ name: "Graph Applications"
|
||||
label: "{{ LABELS.GraphApplication }}"
|
||||
table_name: "applications"
|
||||
properties:
|
||||
- "/@odata.type"
|
||||
- "/appId"
|
||||
- "/appRoleAssignedTo"
|
||||
- "/appRoles"
|
||||
- "/applicationTemplateId"
|
||||
- "/description"
|
||||
- "/createdByAppId"
|
||||
- "/createdDateTime"
|
||||
- "/defaultRedirectUri"
|
||||
- "/displayName"
|
||||
- "/federatedIdentityCredentials"
|
||||
- "/identifierUris"
|
||||
- "/id"
|
||||
- "/isAuthorizationServiceEnabled"
|
||||
- "/isDeviceOnlyAuthSupported"
|
||||
- "/isDisabled"
|
||||
- "/isFallbackPublicClient"
|
||||
- "/isManagementRestricted"
|
||||
- "/keyCredentials"
|
||||
- "/nativeAuthenticationApisEnabled"
|
||||
- "/notes"
|
||||
- "/owners"
|
||||
- "/publicClient"
|
||||
- "/publisherDomain"
|
||||
@@ -27,11 +40,24 @@ cypher: |
|
||||
MERGE (obj:{{ LABELS.GraphObject }} {id: toLower(row.id)})
|
||||
SET obj:{{ LABELS.GraphApplication }}
|
||||
SET obj += {
|
||||
odataType: lower(row["@odata.type"]),
|
||||
displayName: row.displayName,
|
||||
appId: row.appId,
|
||||
description: row.description,
|
||||
notes: row.notes,
|
||||
createdDateTime: row.createdDateTime,
|
||||
createdByAppId: row.createdByAppId,
|
||||
applicationTemplateId: row.applicationTemplateId,
|
||||
isAuthorizationServiceEnabled: row.isAuthorizationServiceEnabled,
|
||||
isDeviceOnlyAuthSupported: row.isDeviceOnlyAuthSupported,
|
||||
isDisabled: row.isDisabled,
|
||||
isFallbackPublicClient: row.isFallbackPublicClient,
|
||||
isManagementRestricted: row.isManagementRestricted,
|
||||
nativeAuthenticationApisEnabled: row.nativeAuthenticationApisEnabled,
|
||||
publisherDomain: row.publisherDomain,
|
||||
signInAudience: row.signInAudience,
|
||||
identifierUris: row.identifierUris,
|
||||
defaultRedirectUri: row.web.redirectUris[0],
|
||||
redirectUris: row.web.redirectUris + row.spa.redirectUris + row.publicClient.redirectUris,
|
||||
publicClientRedirectUris: row.publicClient.redirectUris,
|
||||
spaRedirectUris: row.spa.redirectUris,
|
||||
@@ -107,4 +133,14 @@ cypher: |
|
||||
usage: keyCredential.usage
|
||||
}
|
||||
MERGE (k)-[:{{ REL.AUTHENTICATES }}]->(obj)
|
||||
}
|
||||
}
|
||||
|
||||
// If createdByAppId exists, add created relationship to app that created this app
|
||||
// Assume it exists so MERGE
|
||||
CALL (obj, row) {
|
||||
WITH obj, row
|
||||
WHERE row.createdByAppId IS NOT NULL
|
||||
MERGE (creator:{{ LABELS.GraphObject }} {appId: toLower(row.createdByAppId)})
|
||||
MERGE (creator)-[:{{ REL.CREATED }}]->(obj)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ name: "Graph Devices"
|
||||
label: "{{ LABELS.GraphDevice }}"
|
||||
table_name: "devices"
|
||||
properties:
|
||||
- "/@odata.type"
|
||||
- "/displayName"
|
||||
- "/id"
|
||||
- "/accountEnabled"
|
||||
@@ -24,6 +25,7 @@ cypher: |
|
||||
MERGE (obj:{{ LABELS.GraphObject }} {id: toLower(row.id)})
|
||||
SET obj:{{ LABELS.GraphDevice }}
|
||||
SET obj += {
|
||||
odataType: lower(row["@odata.type"]),
|
||||
displayName: row.displayName,
|
||||
accountEnabled: row.accountEnabled,
|
||||
deviceId: row.deviceId,
|
||||
|
||||
@@ -2,6 +2,7 @@ name: "Graph Groups"
|
||||
label: "{{ LABELS.GraphGroup }}"
|
||||
table_name: "groups"
|
||||
properties:
|
||||
- "/@odata.type"
|
||||
- "/classification"
|
||||
- "/createdByAppId"
|
||||
- "/createdDateTime"
|
||||
@@ -32,6 +33,7 @@ cypher: |
|
||||
MERGE (obj:{{ LABELS.GraphObject }} {id: toLower(row.id)})
|
||||
SET obj:{{ LABELS.GraphGroup }}
|
||||
SET obj += {
|
||||
odataType: lower(row["@odata.type"]),
|
||||
displayName: row.displayName,
|
||||
classification: row.classification,
|
||||
createdByAppId: row.createdByAppId,
|
||||
|
||||
@@ -2,6 +2,9 @@ name: "Service Principals"
|
||||
label: "{{ LABELS.GraphServicePrincipal }}"
|
||||
table_name: "servicePrincipals"
|
||||
properties:
|
||||
- "/@odata.type"
|
||||
- "/agentAppId"
|
||||
- "/agentIdentityBlueprintId"
|
||||
- "/accountEnabled"
|
||||
- "/alternativeNames"
|
||||
- "/appId"
|
||||
@@ -9,6 +12,7 @@ properties:
|
||||
- "/appRoleAssignedTo"
|
||||
- "/appRoleAssignmentRequired"
|
||||
- "/appRoles"
|
||||
- "/createdByAppId"
|
||||
- "/displayName"
|
||||
- "/endpoints"
|
||||
- "/id"
|
||||
@@ -18,6 +22,7 @@ properties:
|
||||
- "/memberOf"
|
||||
- "/owners"
|
||||
- "/passwordCredentials"
|
||||
- "/publishedPermissionScopes"
|
||||
- "/publisherName"
|
||||
- "/replyUrls"
|
||||
- "/servicePrincipalNames"
|
||||
@@ -31,17 +36,24 @@ cypher: |
|
||||
MERGE (obj:{{ LABELS.GraphObject }} {id: toLower(row.id)})
|
||||
SET obj:{{ LABELS.GraphServicePrincipal }}
|
||||
SET obj += {
|
||||
odataType: lower(row["@odata.type"]),
|
||||
displayName: row.displayName,
|
||||
accountEnabled: row.accountEnabled,
|
||||
alternativeNames: row.alternativeNames,
|
||||
appId: row.appId,
|
||||
appOwnerOrganizationId: row.appOwnerOrganizationId,
|
||||
createdByAppId: row.createdByAppId,
|
||||
publisherName: row.publisherName,
|
||||
servicePrincipalType: row.servicePrincipalType,
|
||||
loginUrl: row.loginUrl,
|
||||
logoutUrl: row.logoutUrl,
|
||||
replyUrls: row.replyUrls,
|
||||
servicePrincipalNames: row.servicePrincipalNames
|
||||
servicePrincipalNames: row.servicePrincipalNames,
|
||||
|
||||
// Agent Identity properties
|
||||
agentAppId: row.agentAppId,
|
||||
agentIdentityBlueprintId: row.agentIdentityBlueprintId,
|
||||
instanceMode: row.instanceMode
|
||||
}
|
||||
|
||||
// This match needs to happen with GraphApplication instead of GraphObject to avoid accidental merging with ServicePrincipal later
|
||||
@@ -66,6 +78,31 @@ cypher: |
|
||||
MERGE (o)-[:{{ REL.OWNS }}]->(obj)
|
||||
}
|
||||
|
||||
// CreatedBy App
|
||||
CALL (obj, row) {
|
||||
WITH obj, row
|
||||
WHERE row.createdByAppId IS NOT NULL
|
||||
MERGE (a:{{ LABELS.GraphApplication }} {appId: row.createdByAppId})
|
||||
MERGE (a)-[:{{ REL.CREATED }}]->(obj)
|
||||
}
|
||||
|
||||
// Published Permission Scopes
|
||||
CALL (obj, row) {
|
||||
UNWIND coalesce(row.publishedPermissionScopes, []) AS publishedPermissionScope
|
||||
MERGE (p:{{ LABELS.GraphApplicationScope }} {id: toLower(publishedPermissionScope.id)})
|
||||
SET p += {
|
||||
adminConsentDescription: publishedPermissionScope.adminConsentDescription,
|
||||
adminConsentDisplayName: publishedPermissionScope.adminConsentDisplayName,
|
||||
id: publishedPermissionScope.id,
|
||||
isEnabled: publishedPermissionScope.isEnabled,
|
||||
type: publishedPermissionScope.type,
|
||||
userConsentDescription: publishedPermissionScope.userConsentDescription,
|
||||
userConsentDisplayName: publishedPermissionScope.userConsentDisplayName,
|
||||
value: publishedPermissionScope.value
|
||||
}
|
||||
MERGE (obj)-[:{{ REL.HAS_PUBLISHED_SCOPE }}]->(p)
|
||||
}
|
||||
|
||||
// App Roles
|
||||
CALL (obj, row) {
|
||||
UNWIND coalesce(row.appRoles, []) AS appRole
|
||||
|
||||
@@ -2,7 +2,9 @@ name: "Graph Users"
|
||||
label: "{{ LABELS.GraphUser }}"
|
||||
table_name: "users"
|
||||
properties:
|
||||
- "/@odata.type"
|
||||
- "/accountEnabled"
|
||||
- "/agentIdentityBlueprintId"
|
||||
- "/city"
|
||||
- "/companyName"
|
||||
- "/country"
|
||||
@@ -11,9 +13,13 @@ properties:
|
||||
- "/displayName"
|
||||
- "/givenName"
|
||||
- "/id"
|
||||
- "/identityParentId"
|
||||
- "/identityParentType"
|
||||
- "/jobTitle"
|
||||
- "/lastPasswordChangeDateTime"
|
||||
- "/mail"
|
||||
- "/mailNickname"
|
||||
- "/manager"
|
||||
- "/memberOf"
|
||||
- "/mobilePhone"
|
||||
- "/officeLocation"
|
||||
@@ -39,6 +45,7 @@ cypher: |
|
||||
MERGE (obj:{{ LABELS.GraphObject }} {id: toLower(row.id)})
|
||||
SET obj:{{ LABELS.GraphUser }}
|
||||
SET obj += {
|
||||
odataType: lower(row["@odata.type"]),
|
||||
accountEnabled: row.accountEnabled,
|
||||
city: row.city,
|
||||
companyName: row.companyName,
|
||||
@@ -47,7 +54,11 @@ cypher: |
|
||||
department: row.department,
|
||||
displayName: row.displayName,
|
||||
givenName: row.givenName,
|
||||
agentIdentityBlueprintId: row.agentIdentityBlueprintId,
|
||||
identityParentId: row.identityParentId,
|
||||
identityParentType: row.identityParentType,
|
||||
jobTitle: row.jobTitle,
|
||||
lastPasswordChangeDateTime: row.lastPasswordChangeDateTime,
|
||||
mail: row.mail,
|
||||
mailNickname: row.mailNickname,
|
||||
mobilePhone: row.mobilePhone,
|
||||
@@ -75,4 +86,13 @@ cypher: |
|
||||
UNWIND coalesce(row.memberOf, []) AS memberOf
|
||||
MERGE (m:{{ LABELS.GraphObject }} {id: toLower(memberOf)})
|
||||
MERGE (obj)-[:{{ REL.MEMBER_OF }}]->(m)
|
||||
}
|
||||
}
|
||||
|
||||
// If manager is present, create a relationship from manager to the user
|
||||
// Manager is a single value, not an array, so we don't need to unwind it
|
||||
CALL (obj, row) {
|
||||
WITH obj, row
|
||||
WHERE row.manager IS NOT NULL
|
||||
MERGE (m:{{ LABELS.GraphObject }} {id: toLower(row.manager)})
|
||||
MERGE (m)-[:{{ REL.MANAGES }}]->(obj)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ REL:
|
||||
CAN_ACCESS_REGISTRY: "CAN_ACCESS_REGISTRY"
|
||||
CONTAINS: "CONTAINS"
|
||||
CONTAINS_IMAGE: "CONTAINS_IMAGE"
|
||||
CREATED: "CREATED"
|
||||
DELEGATED_TO_CLIENT: "DELEGATED_TO_CLIENT"
|
||||
DEPLOYS: "DEPLOYS"
|
||||
ENCRYPTED_BY: "ENCRYPTED_BY"
|
||||
@@ -42,6 +43,7 @@ REL:
|
||||
HAS_PEERING: "HAS_PEERING"
|
||||
HAS_POLICY: "HAS_POLICY"
|
||||
HAS_PRIVATE_ENDPOINT: "HAS_PRIVATE_ENDPOINT"
|
||||
HAS_PUBLISHED_SCOPE: "HAS_PUBLISHED_SCOPE"
|
||||
HAS_RESOURCE: "HAS_RESOURCE"
|
||||
HAS_RESTOREPOINTS: "HAS_RESTOREPOINTS"
|
||||
HAS_ROLE: "HAS_ROLE"
|
||||
@@ -201,6 +203,7 @@ LABELS:
|
||||
# Graph objects
|
||||
GraphObject: "GraphObject"
|
||||
GraphApplication: "GraphApplication"
|
||||
GraphApplicationScope: "GraphApplicationScope"
|
||||
GraphDevice: "GraphDevice"
|
||||
GraphGroup: "GraphGroup"
|
||||
GraphRole: "GraphRole"
|
||||
|
||||
@@ -64,6 +64,7 @@ nav = [
|
||||
"analysis/graph-nodes/graph-user.md",
|
||||
"analysis/graph-nodes/graph-group.md",
|
||||
"analysis/graph-nodes/graph-application.md",
|
||||
"analysis/graph-nodes/graph-application-scope.md",
|
||||
"analysis/graph-nodes/graph-service-principal.md",
|
||||
"analysis/graph-nodes/graph-device.md",
|
||||
"analysis/graph-nodes/graph-role.md",
|
||||
@@ -319,6 +320,7 @@ nav = [
|
||||
"analysis/edges/can-access-registry.md",
|
||||
"analysis/edges/contains.md",
|
||||
"analysis/edges/contains-image.md",
|
||||
"analysis/edges/created.md",
|
||||
"analysis/edges/delegated-to-client.md",
|
||||
"analysis/edges/encrypted-by.md",
|
||||
"analysis/edges/has-alert.md",
|
||||
@@ -349,6 +351,7 @@ nav = [
|
||||
"analysis/edges/has-peering.md",
|
||||
"analysis/edges/has-policy.md",
|
||||
"analysis/edges/has-private-endpoint.md",
|
||||
"analysis/edges/has-published-scope.md",
|
||||
"analysis/edges/has-restorepoints.md",
|
||||
"analysis/edges/has-role.md",
|
||||
"analysis/edges/has-rulestack.md",
|
||||
|
||||
Reference in new issue
Block a user