Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ okta-config.toml
# Jira credentials — never commit
jira-config.toml

# CrowdStrike credentials — never commit
crowdstrike-config.toml

# Elastic credentials — never commit
elastic-config.toml

Expand Down
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 15 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = [".", "crates/tenable-rs", "crates/okta-rs", "crates/jira-rs", "crates/elastic-rs", "crates/jumpcloud-rs", "crates/jamf-rs", "crates/github-rs", "grabber-desktop/src-tauri"]
members = [".", "crates/tenable-rs", "crates/okta-rs", "crates/jira-rs", "crates/crowdstrike-rs", "crates/elastic-rs", "crates/jumpcloud-rs", "crates/jamf-rs", "crates/github-rs", "grabber-desktop/src-tauri"]
resolver = "2"

[package]
Expand Down Expand Up @@ -116,6 +116,9 @@ okta-rs = { path = "crates/okta-rs", optional = true }
# Jira — only compiled with `--features jira`
jira-rs = { path = "crates/jira-rs", optional = true }

# CrowdStrike — only compiled with `--features crowdstrike`
crowdstrike-rs = { path = "crates/crowdstrike-rs", optional = true }

# JumpCloud — only compiled with `--features jumpcloud`
jumpcloud-rs = { path = "crates/jumpcloud-rs", optional = true }

Expand All @@ -129,16 +132,17 @@ jamf-rs = { path = "crates/jamf-rs", optional = true }
github-rs = { path = "crates/github-rs", optional = true }

[features]
default = ["tenable", "okta", "jira", "elastic", "jumpcloud", "jamf", "github"]
azure = ["dep:azure_identity", "dep:azure_mgmt_monitor", "dep:azure_mgmt_resources"]
gcp = ["dep:google-cloud-auth"]
tenable = ["dep:tenable-rs"]
okta = ["dep:okta-rs"]
jira = ["dep:jira-rs"]
elastic = ["dep:elastic-rs"]
jumpcloud = ["dep:jumpcloud-rs"]
jamf = ["dep:jamf-rs"]
github = ["dep:github-rs"]
default = ["tenable", "okta", "jira", "crowdstrike", "elastic", "jumpcloud", "jamf", "github"]
azure = ["dep:azure_identity", "dep:azure_mgmt_monitor", "dep:azure_mgmt_resources"]
gcp = ["dep:google-cloud-auth"]
tenable = ["dep:tenable-rs"]
okta = ["dep:okta-rs"]
jira = ["dep:jira-rs"]
crowdstrike = ["dep:crowdstrike-rs"]
elastic = ["dep:elastic-rs"]
jumpcloud = ["dep:jumpcloud-rs"]
jamf = ["dep:jamf-rs"]
github = ["dep:github-rs"]

[dev-dependencies]
tempfile = "3"
52 changes: 50 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The Grabber

The Grabber. Collects current-state snapshots and time-windowed audit records from AWS, Okta, Jira, Tenable, Jamf, and GitHub, writing them as CSV and JSON. Supports exporting inventory and POA&M artifacts using FedRAMP-aligned templates, suitable for FedRAMP, SOC 2, HIPAA, or internal audits.
The Grabber. Collects current-state snapshots and time-windowed audit records from AWS, Okta, Jira, Tenable, CrowdStrike, Elastic, Jamf, and GitHub, writing them as CSV and JSON. Supports exporting inventory and POA&M artifacts using FedRAMP-aligned templates, suitable for FedRAMP, SOC 2, HIPAA, or internal audits.

![Alt text](assets/1.Grabber_LandingPage.png)

Expand All @@ -10,7 +10,7 @@ The Grabber. Collects current-state snapshots and time-windowed audit records fr

- **Interactive TUI** — wizard-style interface for selecting accounts, date ranges, collectors, and options
- **Multi-account support** — TOML config drives an account picker; each account maps to an AWS SSO profile
- **200+ collectors across six providers** — 144 AWS, 25 Okta, 28 Jira, 5 Tenable, 9 Jamf, 10 GitHub (see `evidence-list.md` for the current catalog)
- **200+ collectors across eight providers** — 144 AWS, 25 Okta, 28 Jira, 5 Tenable, 10 Elastic, 9 Jamf, 10 GitHub, 5 CrowdStrike (see `evidence-list.md` for the current catalog)
- **Dual output formats** — structured JSON (inventory/policy data) and CSV (tabular snapshots)
- **Chain-of-custody audit trail** — per-run `CHAIN-OF-CUSTODY-*.json` and an append-only `CHAIN-OF-CUSTODY.jsonl` log capture operator identity, hostname, AWS caller ARN, and the sanitized CLI invocation
- **Run manifest** — `RUN-MANIFEST-*.json` records every collector's outcome (success/empty/error/timeout), record count, and file size
Expand Down Expand Up @@ -1012,6 +1012,54 @@ Or via environment variables (env wins over TOML):

---

## CrowdStrike

Optional feature — build with `--features crowdstrike` (enabled by default).

### Configuration

Create `crowdstrike-config.toml` in the repo root (gitignored):

```toml
[[account]]
name = "CrowdStrike"
provider = "crowdstrike"
description = "CrowdStrike Falcon platform"
output_dir = "./evidence-output/crowdstrike"
crowdstrike_client_id = ""
crowdstrike_client_secret = ""
# crowdstrike_base_url = "" # omit for US-1; see Falcon cloud table below
```

Or via environment variables (env wins over TOML):

- `CROWDSTRIKE_CLIENT_ID`
- `CROWDSTRIKE_CLIENT_SECRET`
- `CROWDSTRIKE_BASE_URL`

Create an OAuth2 API client in the Falcon console: **Support and resources → API clients and keys → Create API client**, granting read scope for Hosts, Alerts, Vulnerabilities (Spotlight), Prevention Policies, and Sensor Update Policies.

### Falcon clouds

| Cloud | Base URL |
|-------|----------|
| US-1 (default) | `https://api.crowdstrike.com` |
| US-2 | `https://api.us-2.crowdstrike.com` |
| EU-1 | `https://api.eu-1.crowdstrike.com` |
| US-GOV-1 | `https://api.laggar.gcw.crowdstrike.com` |

### Collectors

| Key | Output | Description |
|-----|--------|-------------|
| `crowdstrike-hosts` | CSV | Host/device inventory |
| `crowdstrike-alerts` | CSV | Detections and incidents (unified Alerts API), time-windowed |
| `crowdstrike-vulnerabilities` | CSV | Spotlight vulnerability findings |
| `crowdstrike-prevention-policies` | CSV | Prevention policy configuration |
| `crowdstrike-sensor-update-policies` | CSV | Sensor update (N/N-1/N-2) policy configuration |

---

## Elastic

Optional feature — build with `--features elastic` (enabled by default).
Expand Down
9 changes: 9 additions & 0 deletions cli-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,15 @@ Credentials come from `github-config.toml` (or `GITHUB_ORG` / `GITHUB_TOKEN` / `

Credentials come from `tenable-config.toml` (or `TENABLE_ACCESS_KEY` / `TENABLE_SECRET_KEY`). Tenable is region-agnostic — `--region`, `--all-regions`, and `--regions` have no effect.

## CrowdStrike

```bash
./target/release/grabber \
--collectors crowdstrike-hosts,crowdstrike-alerts,crowdstrike-vulnerabilities,crowdstrike-prevention-policies,crowdstrike-sensor-update-policies
```

Credentials come from `crowdstrike-config.toml` (or `CROWDSTRIKE_CLIENT_ID` / `CROWDSTRIKE_CLIENT_SECRET` / `CROWDSTRIKE_BASE_URL`). CrowdStrike is region-agnostic — `--region`, `--all-regions`, and `--regions` have no effect. `crowdstrike-alerts` respects `--start-date`/`--end-date` (or `--lookback`) like any other time-windowed collector; the others are point-in-time snapshots.

## JumpCloud

### JumpCloud — default collector set
Expand Down
20 changes: 20 additions & 0 deletions crates/crowdstrike-rs/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "crowdstrike-rs"
version = "0.1.0"
edition = "2021"
description = "Async Rust client for the CrowdStrike Falcon REST API"
license = "MIT OR Apache-2.0"

[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["time", "sync"] }
thiserror = "2"
anyhow = "1"
futures = "0.3"
chrono = { version = "0.4", features = ["serde"] }

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
wiremock = "0.6"
66 changes: 66 additions & 0 deletions crates/crowdstrike-rs/src/api/alerts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use serde::Deserialize;
use serde_json::json;

use crate::client::CrowdStrikeClient;
use crate::error::CrowdStrikeError;
use crate::types::alert::Alert;

pub struct AlertsApi<'c>(pub(crate) &'c CrowdStrikeClient);

#[derive(Debug, Deserialize, Default)]
struct Pagination {
#[serde(default)]
after: Option<String>,
}

#[derive(Debug, Deserialize, Default)]
struct Meta {
#[serde(default)]
pagination: Pagination,
}

#[derive(Debug, Deserialize)]
struct AlertsResponse {
#[serde(default)]
resources: Vec<Alert>,
#[serde(default)]
meta: Meta,
}

impl<'c> AlertsApi<'c> {
/// POST /alerts/combined/alerts/v1 — detections + incidents (CrowdStrike's
/// unified Alerts collection), filtered by `created_timestamp` between
/// `since` and `until` (both RFC3339), `after`-cursor paginated.
pub async fn list_all(&self, since: &str, until: &str) -> Result<Vec<Alert>, CrowdStrikeError> {
let filter = format!("created_timestamp:['{since}'+TO+'{until}']");
let mut all = Vec::new();
let mut after: Option<String> = None;
loop {
let mut body = json!({
"filter": filter,
"limit": 1000,
"sort": "created_timestamp|asc",
});
if let Some(ref a) = after {
body["after"] = json!(a);
}
let resp = self
.0
.post_json("/alerts/combined/alerts/v1", &body)
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(CrowdStrikeError::Api { status, message });
}
let page: AlertsResponse = resp.json().await?;
let is_empty = page.resources.is_empty();
all.extend(page.resources);
after = page.meta.pagination.after.filter(|a| !a.is_empty());
if after.is_none() || is_empty {
break;
}
}
Ok(all)
}
}
40 changes: 40 additions & 0 deletions crates/crowdstrike-rs/src/api/hosts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use serde::Deserialize;

use crate::client::CrowdStrikeClient;
use crate::error::CrowdStrikeError;
use crate::types::host::Host;

pub struct HostsApi<'c>(pub(crate) &'c CrowdStrikeClient);

#[derive(Debug, Deserialize)]
struct DevicesResponse {
#[serde(default)]
resources: Vec<Host>,
}

impl<'c> HostsApi<'c> {
/// GET /devices/combined/devices/v1 — full host inventory.
/// Offset-paginated: stops when a page returns fewer than `limit` rows.
pub async fn list_all(&self) -> Result<Vec<Host>, CrowdStrikeError> {
let limit = 500u32;
let mut offset = 0u32;
let mut all = Vec::new();
loop {
let path = format!("/devices/combined/devices/v1?limit={limit}&offset={offset}");
let resp = self.0.get(&path).await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(CrowdStrikeError::Api { status, message });
}
let page: DevicesResponse = resp.json().await?;
let got = page.resources.len() as u32;
all.extend(page.resources);
if got < limit {
break;
}
offset += got;
}
Ok(all)
}
}
11 changes: 11 additions & 0 deletions crates/crowdstrike-rs/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
pub mod alerts;
pub mod hosts;
pub mod prevention_policies;
pub mod sensor_update_policies;
pub mod vulnerabilities;

pub use alerts::AlertsApi;
pub use hosts::HostsApi;
pub use prevention_policies::PreventionPoliciesApi;
pub use sensor_update_policies::SensorUpdatePoliciesApi;
pub use vulnerabilities::VulnerabilitiesApi;
40 changes: 40 additions & 0 deletions crates/crowdstrike-rs/src/api/prevention_policies.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use serde::Deserialize;

use crate::client::CrowdStrikeClient;
use crate::error::CrowdStrikeError;
use crate::types::prevention_policy::PreventionPolicy;

pub struct PreventionPoliciesApi<'c>(pub(crate) &'c CrowdStrikeClient);

#[derive(Debug, Deserialize)]
struct PoliciesResponse {
#[serde(default)]
resources: Vec<PreventionPolicy>,
}

impl<'c> PreventionPoliciesApi<'c> {
/// GET /policy/combined/prevention/v1 — prevention policy configuration.
/// Offset-paginated: stops when a page returns fewer than `limit` rows.
pub async fn list_all(&self) -> Result<Vec<PreventionPolicy>, CrowdStrikeError> {
let limit = 100u32;
let mut offset = 0u32;
let mut all = Vec::new();
loop {
let path = format!("/policy/combined/prevention/v1?limit={limit}&offset={offset}");
let resp = self.0.get(&path).await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(CrowdStrikeError::Api { status, message });
}
let page: PoliciesResponse = resp.json().await?;
let got = page.resources.len() as u32;
all.extend(page.resources);
if got < limit {
break;
}
offset += got;
}
Ok(all)
}
}
41 changes: 41 additions & 0 deletions crates/crowdstrike-rs/src/api/sensor_update_policies.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use serde::Deserialize;

use crate::client::CrowdStrikeClient;
use crate::error::CrowdStrikeError;
use crate::types::sensor_update_policy::SensorUpdatePolicy;

pub struct SensorUpdatePoliciesApi<'c>(pub(crate) &'c CrowdStrikeClient);

#[derive(Debug, Deserialize)]
struct PoliciesResponse {
#[serde(default)]
resources: Vec<SensorUpdatePolicy>,
}

impl<'c> SensorUpdatePoliciesApi<'c> {
/// GET /policy/combined/sensor-update/v2 — sensor update (N/N-1/N-2) policy
/// configuration, including uninstall protection. Offset-paginated: stops
/// when a page returns fewer than `limit` rows.
pub async fn list_all(&self) -> Result<Vec<SensorUpdatePolicy>, CrowdStrikeError> {
let limit = 100u32;
let mut offset = 0u32;
let mut all = Vec::new();
loop {
let path = format!("/policy/combined/sensor-update/v2?limit={limit}&offset={offset}");
let resp = self.0.get(&path).await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(CrowdStrikeError::Api { status, message });
}
let page: PoliciesResponse = resp.json().await?;
let got = page.resources.len() as u32;
all.extend(page.resources);
if got < limit {
break;
}
offset += got;
}
Ok(all)
}
}
Loading