3 Commits
20 changed files with 8195 additions and 161 deletions

No files matched your search

+7
View File
@@ -5,6 +5,13 @@ 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
Generated
+1 -1
View File
@@ -344,7 +344,7 @@ dependencies = [
[[package]]
name = "cirro"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"async-trait",
"base64",
+1 -1
View File
@@ -2,7 +2,7 @@
edition = "2024"
name = "cirro"
repository = "https://github.com/bishopfox/cirro"
version = "1.3.0"
version = "1.4.0"
[features]
azure = ["collector"]
+4
View File
@@ -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
+1
View File
@@ -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"
-2
View File
@@ -8,5 +8,3 @@ Microsoft Graph directory entities use the base `GraphObject` label. Fully colle
- `id` - Unique object identifier (primary key)
- `odataType` - Lowercase `@odata.type`, when present, on users, groups, applications, service principals, devices, and administrative units
Permission scopes extracted from service principals use the standalone [`GraphApplicationScope`](graph-nodes/graph-application-scope.md) label.
@@ -41,7 +41,7 @@ Represents Entra ID applications collected from Microsoft Graph.
- **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
@@ -50,7 +50,6 @@ Represents Entra ID applications collected from Microsoft Graph.
- **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
@@ -69,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,7 @@ 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
@@ -24,17 +25,19 @@ Represents Entra ID service principals collected from Microsoft Graph.
- `replyUrls` - Array of reply URLs
- `servicePrincipalNames` - Array of service principal names
Agent identity IDs are stored as properties; they do not create separate agent identity relationships.
## 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
@@ -42,3 +45,17 @@ Agent identity IDs are stored as properties; they do not create separate agent i
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
```
+19 -3
View File
@@ -46,7 +46,7 @@ Represents Entra ID users collected from Microsoft Graph.
### Incoming
- **GraphUser** ← `MANAGES` ← **GraphObject** - The user's manager. Cirro collects this relationship only for enabled member users (`accountEnabled: true` and `userType: Member`). Users without an assigned or accessible manager have no `MANAGES` relationship.
- **GraphUser** ← `MANAGES` ← **GraphObject** - The user's manager
### Outgoing
@@ -76,7 +76,23 @@ RETURN u.displayName, collect(g.displayName) AS groups
```cypher
// Find enabled member users and their managers
MATCH (manager:GraphUser)-[:MANAGES]->(user:GraphUser)
MATCH (manager:GraphObject)-[:MANAGES]->(user:GraphUser)
WHERE user.accountEnabled = true AND user.userType = "Member"
RETURN user.displayName, manager.displayName
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
View File
@@ -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>
+10 -1
View File
@@ -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`
+31 -5
View File
@@ -46,15 +46,41 @@ cirro collect ts <auth-method> --output-path cirro_ts_socket.json
cirro collect az azcli --debug
```
## Microsoft Graph User Enrichment
## Restricted Guest Collection
User collection fetches `lastPasswordChangeDateTime` with a per-user `$select` request and merges it into the collected user record when returned. Manager lookups run only for enabled member users (`accountEnabled: true`, `userType: Member`). The collected `manager` value is the manager's object ID; graph ingestion creates a `MANAGES` edge from the manager to the user.
Use `--restricted-guest` to collect Microsoft Graph data as a restricted Entra ID guest. These options are available with all Azure authentication commands:
An unassigned manager (HTTP 404) is skipped. Other failed enrichment requests can leave properties or relationships absent; use debug logs to investigate incomplete data.
| 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. |
Graph enrichment uses batches of up to 20 requests. Individual requests returning HTTP 429 or 5xx are retried up to five times, honoring `Retry-After` when supplied. Throttled responses without that header use a 25-second fallback delay.
`--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.
+11 -2
View File
@@ -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)
+24
View File
@@ -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,
+86 -10
View File
@@ -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
+201 -130
View File
@@ -87,6 +87,38 @@ struct BatchOutcome {
failures: Vec<BatchFailure>,
}
/// Returns a valid cached Graph access token, refreshing it when necessary.
async fn cached_graph_token(collector: &Collector) -> Result<Arc<String>, CirroError> {
let mut cache = TOKEN_CACHE.lock().await;
let needs_refresh = cache
.as_ref()
.is_none_or(|cached_token| cached_token.is_expired_or_not_set());
if needs_refresh {
debug!("Fetching new Graph API token");
let token = collector.msgraph_credential.get_token().await?;
if let Some(expires) = token.expires_on {
let now = chrono::Utc::now();
let time_until_expiry = expires.signed_duration_since(now);
debug!(
"New token expires at {} (valid for {} minutes)",
expires,
time_until_expiry.num_minutes()
);
} else {
debug!("New token has no expiration time set");
}
*cache = Some(token);
}
cache
.as_ref()
.map(|token| Arc::clone(&token.access_token))
.ok_or_else(|| CirroError::AuthError("Graph token cache was not initialized".to_string()))
}
/// Represents a Graph object with its resource type, query parameters, and optional expand properties
#[derive(Clone)]
struct GraphObject {
@@ -163,25 +195,15 @@ pub async fn enumerate_graph(
info!("Starting Graph enumeration");
// Initialize our token cache with a valid token
{
let mut cache = TOKEN_CACHE.lock().await;
if cache.is_none() || cache.as_ref().unwrap().is_expired_or_not_set() {
debug!("Pre-fetching initial Graph API token");
match collector.msgraph_credential.get_token().await {
Ok(token) => {
*cache = Some(token);
info!("MS Graph token retrieved successfully");
}
Err(e) => {
warn!(
"Failed to acquire MS Graph token - ending Graph enumeration: {}",
e
);
return Ok(());
}
}
}
debug!("Pre-fetching initial Graph API token");
if let Err(e) = cached_graph_token(&collector).await {
warn!(
"Failed to acquire MS Graph token - ending Graph enumeration: {}",
e
);
return Ok(());
}
info!("MS Graph token retrieved successfully");
// Create all enumerators with their configurations.
// `inline_expand` identifies the primary navigation property to fetch for
@@ -399,7 +421,7 @@ async fn query_objects(
);
// Resume from saved state if available (uri, previously collected count, prior elapsed time)
let (mut next_uri, mut total_fetched, prior_elapsed) =
let (initial_uri, mut total_fetched, prior_elapsed) =
if let Some((uri, count, elapsed)) = resume {
info!(
"Resuming {} from saved state ({} objects, {:.1}s prior)",
@@ -443,25 +465,30 @@ async fn query_objects(
};
// Process each page as it arrives instead of accumulating all objects
let mut pager = GraphPager::new(initial_uri);
loop {
debug!("Fetching data from: {}", next_uri);
let Some(request_uri) = pager.next_uri().map(str::to_owned) else {
break;
};
debug!("Fetching data from: {}", request_uri);
let response_data = paged_graph_request(&collector, &next_uri)
let response_data = pager
.next_page(&collector)
.await
.map_err(|e| {
error!("Failed to fetch data from Graph API: {} - {}", &next_uri, e);
error!(
"Failed to fetch data from Graph API: {} - {}",
request_uri, e
);
e
})?;
if let Some(error) = response_data.get("@odata.error") {
let error_message = error
.get("message")
.and_then(|m| m.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
error!("Graph API error: {}", error_message);
return Err(CirroError::ODataError(error_message.to_string()));
}
})?
.ok_or_else(|| {
CirroError::InvalidData(format!(
"Graph pager stopped before returning {}",
request_uri
))
})?
.into_success()?;
// Process this page immediately, then discard — memory stays bounded to page size
if let Some(values) = response_data.get("value").and_then(|v| v.as_array()) {
@@ -661,19 +688,13 @@ async fn query_objects(
}
// response_data and page values are dropped here — memory freed each iteration
// Check for next link
if let Some(next_link) = response_data
.get("@odata.nextLink")
.and_then(|v| v.as_str())
{
next_uri = next_link.to_string();
if let Some(next_uri) = pager.next_uri().map(str::to_owned) {
// Save pagination state so we can resume if interrupted
if let Some(sender) = &collector.db_writer {
let _ = sender
.send(DBWriteMessage::SaveState {
resource_type: graph_object_name.to_string(),
next_uri: next_uri.clone(),
next_uri,
total_collected: total_fetched,
elapsed_secs: prior_elapsed + start_time.elapsed().as_secs_f64(),
})
@@ -976,20 +997,7 @@ async fn send_batch_graph_request(
let batch_body = serde_json::json!({ "requests": batch_requests });
let batch_url = format!("{}/beta/$batch", collector.cloud_endpoints.msgraph_url);
let token_str = {
let mut cache = TOKEN_CACHE.lock().await;
let need_new_token = match &*cache {
Some(cached_token) => cached_token.is_expired_or_not_set(),
None => true,
};
if need_new_token {
let token = collector.msgraph_credential.get_token().await?;
*cache = Some(token.clone());
Arc::clone(&token.access_token)
} else {
Arc::clone(&cache.as_ref().unwrap().access_token)
}
};
let token_str = cached_graph_token(collector).await?;
let response = HTTP_CLIENT
.post(&batch_url)
@@ -1025,21 +1033,12 @@ async fn send_batch_graph_request(
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut next_link = success
.body
.get("@odata.nextLink")
.and_then(|v| v.as_str())
.map(str::to_string);
while let Some(url) = next_link {
let next_page = paged_graph_request(collector, &url).await?;
if let Some(values) = next_page.get("value").and_then(|v| v.as_array()) {
let mut pager = GraphPager::from_next_link(&success.body);
while let Some(response) = pager.next_page(collector).await? {
let next_page = response.into_success()?;
if let Some(values) = next_page.get("value").and_then(Value::as_array) {
combined_values.extend(values.iter().cloned());
}
next_link = next_page
.get("@odata.nextLink")
.and_then(|v| v.as_str())
.map(str::to_string);
}
if let Some(body) = success.body.as_object_mut() {
@@ -1148,56 +1147,99 @@ fn is_transient_batch_status(status: u16) -> bool {
status == StatusCode::TOO_MANY_REQUESTS.as_u16() || (500..600).contains(&status)
}
/// Performs a paged request to the Graph API with optimized token caching
pub async fn paged_graph_request(
collector: &Collector,
uri: &str,
) -> Result<serde_json::Value, CirroError> {
// Build the full URL correctly
let graph_url = if uri.starts_with(&collector.cloud_endpoints.msgraph_url) {
uri.to_owned()
} else {
format!(
"{}/beta/{}",
collector.cloud_endpoints.msgraph_url,
uri.trim_start_matches('/')
)
};
pub(super) struct GraphGetResponse {
pub(super) status: StatusCode,
pub(super) body: serde_json::Value,
}
// Get the access token, using cache when possible
let token_str = {
let mut cache = TOKEN_CACHE.lock().await;
impl GraphGetResponse {
/// Converts a status-aware response into the strict response shape used by
/// normal Graph enumeration.
pub(super) fn into_success(self) -> Result<Value, CirroError> {
if !self.status.is_success() {
let error_text = self
.body
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| self.body.to_string());
return Err(CirroError::HttpError(format!(
"HTTP {} - {}",
self.status.as_u16(),
error_text
)));
}
// Check if we need a new token based on the token's actual expiration
let need_new_token = match &*cache {
Some(cached_token) => cached_token.is_expired_or_not_set(),
None => true,
if let Some(error) = self.body.get("@odata.error") {
let message = error
.pointer("/message/value")
.and_then(Value::as_str)
.or_else(|| error.get("message").and_then(Value::as_str))
.unwrap_or("unknown OData error");
return Err(CirroError::ODataError(message.to_string()));
}
Ok(self.body)
}
}
/// Iterates Graph pages without deciding how callers should handle HTTP
/// statuses. Strict callers convert each page with `into_success`; restricted
/// callers can inspect expected branch-local failures first.
pub(super) struct GraphPager {
next_uri: Option<String>,
}
impl GraphPager {
pub(super) fn new(initial_uri: impl Into<String>) -> Self {
Self {
next_uri: Some(initial_uri.into()),
}
}
fn from_next_link(body: &Value) -> Self {
Self {
next_uri: graph_next_link(body),
}
}
pub(super) fn next_uri(&self) -> Option<&str> {
self.next_uri.as_deref()
}
pub(super) async fn next_page(
&mut self,
collector: &Collector,
) -> Result<Option<GraphGetResponse>, CirroError> {
let Some(uri) = self.next_uri.take() else {
return Ok(None);
};
if need_new_token {
debug!("Fetching new Graph API token");
let token = collector.msgraph_credential.get_token().await?;
// Log expiration information for debugging
if let Some(expires) = token.expires_on {
let now = chrono::Utc::now();
let time_until_expiry = expires.signed_duration_since(now);
debug!(
"New token expires at {} (valid for {} minutes)",
expires,
time_until_expiry.num_minutes()
);
} else {
debug!("New token has no expiration time set");
}
*cache = Some(token.clone());
Arc::clone(&token.access_token)
} else {
// Return a clone of the cached token
Arc::clone(&cache.as_ref().unwrap().access_token)
let response = graph_get_response(collector, &uri).await?;
if response.status.is_success() {
self.next_uri = graph_next_link(&response.body);
}
};
Ok(Some(response))
}
}
fn graph_next_link(body: &Value) -> Option<String> {
body.get("@odata.nextLink")
.and_then(Value::as_str)
.map(str::to_string)
}
/// Performs a GET request to the Graph API with optimized token caching.
///
/// Unlike [`paged_graph_request`], this returns non-success HTTP responses to
/// the caller so collection strategies can handle expected statuses directly.
pub(super) async fn graph_get_response(
collector: &Collector,
uri: &str,
) -> Result<GraphGetResponse, CirroError> {
let graph_url = graph_request_url(collector, uri)?;
let token_str = cached_graph_token(collector).await?;
let mut retries = 0;
let max_retries = 5;
@@ -1281,27 +1323,56 @@ pub async fn paged_graph_request(
retries += 1;
continue; // Retry the request after waiting
} else {
let error_text = response.text().await?;
return Err(CirroError::HttpError(format!(
"HTTP {} - {}",
status.as_u16(),
error_text
)));
break response;
}
};
// Parse the response data
let response_data: Value = response.json::<Value>().await?;
let status = response.status();
let response_bytes = response.bytes().await?;
let body = if status.is_success() {
serde_json::from_slice::<Value>(&response_bytes)?
} else {
serde_json::from_slice::<Value>(&response_bytes).unwrap_or_else(|_| {
Value::String(String::from_utf8_lossy(&response_bytes).into_owned())
})
};
// Check for OData error
if let Some(err) = response_data.get("@odata.error") {
let msg = err
.get("message")
.and_then(|m| m.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("unknown odata.error");
return Err(CirroError::ODataError(msg.to_string()));
Ok(GraphGetResponse { status, body })
}
fn graph_request_url(collector: &Collector, uri: &str) -> Result<String, CirroError> {
if uri.starts_with("https://") || uri.starts_with("http://") {
let graph_endpoint =
reqwest::Url::parse(&collector.cloud_endpoints.msgraph_url).map_err(|error| {
CirroError::InvalidData(format!(
"Configured Microsoft Graph endpoint is invalid: {}",
error
))
})?;
let absolute_uri = reqwest::Url::parse(uri).map_err(|error| {
CirroError::InvalidData(format!("Microsoft Graph URL is invalid: {}", error))
})?;
if absolute_uri.scheme() != "https" || absolute_uri.origin() != graph_endpoint.origin() {
return Err(CirroError::InvalidData(
"Microsoft Graph URL must use HTTPS and the configured Graph origin".to_string(),
));
}
return Ok(uri.to_owned());
}
Ok(response_data)
Ok(format!(
"{}/beta/{}",
collector.cloud_endpoints.msgraph_url,
uri.trim_start_matches('/')
))
}
/// Performs a paged request to the Graph API with optimized token caching.
pub async fn paged_graph_request(
collector: &Collector,
uri: &str,
) -> Result<serde_json::Value, CirroError> {
graph_get_response(collector, uri).await?.into_success()
}
+2
View File
@@ -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