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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ thiserror = "1"
indicatif = "0.17"
chrono = { version = "0.4", features = ["serde"] }
libc = "0.2"
async-trait = "0.1"
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-sts = "1"
aws-sdk-iam = "1"
aws-sdk-cloudformation = "1"

[dev-dependencies]
mockito = "1"
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ plerion --profile prod findings list
| `alerts list` | Risk-based alerts (supports `--all`) |
| `audit-logs list` | Audit logs (supports `--all`) |
| `integrations list` | Cloud integrations (supports `--all`) |
| `integrations add aws` | Onboard an AWS account end-to-end (preflight, CloudFormation deploy, registration) |
| `risks list` | Security risks (supports `--all`) |
| `vulnerabilities list` | Vulnerabilities (supports `--all`) |
| `vulnerabilities exemptions list/get/create/update/delete` | Vulnerability exemptions (list supports `--all`) |
Expand All @@ -183,6 +184,32 @@ plerion --profile prod findings list

Most list commands accept `--per-page <n>` (default 50) and `--sort-by`/`--sort-order`. Run `plerion <command> <subcommand> --help` for all options.

### Onboarding an AWS account

```bash
# Preview everything the command would do (no network calls)
plerion integrations add aws --dry-run

# Run all checks without deploying
plerion integrations add aws --aws-profile prod --aws-region ap-southeast-2 --validate-only

# Onboard (interactive confirmation before anything is created)
plerion integrations add aws --aws-profile prod --aws-region ap-southeast-2

# Non-interactive (CI): pin the expected account and skip the prompt
plerion integrations add aws --yes --expect-account-id 123456789012
```

AWS credentials come from the standard AWS credential chain (`--aws-profile`
selects a profile; SSO/Identity Center works out of the box). The command runs
preflight checks (account identity, existing stacks/integrations, an advisory
IAM permission simulation, template validation), asks for one confirmation,
deploys Plerion's CloudFormation stack, and the stack registers the
integration automatically. Exit codes: `0` success, `1` config/API error,
`2` preflight failure, `3` deploy failure, `4` account already onboarded
(override with `--allow-existing`). If the Plerion-managed scanning service
account cannot be resolved automatically, pass `--service-account-id <id>`.

> **Note:** The global `--region` flag selects the API endpoint region. Some commands like `findings list` also have a `--region` flag that filters by cloud resource region (e.g. `us-east-1`). These are independent.

## Output formats
Expand Down
21 changes: 13 additions & 8 deletions src/api/endpoints/aws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@ pub async fn get_cloudformation_template(
client.execute(req).await
}

/// Generate a temporary integration token.
///
/// With `Some(integration_id)` the token is scoped to an existing integration
/// (the `plerion aws generate-token` behavior). With `None` the request is a
/// bare POST, which mints the onboarding token used to register a NEW AWS
/// account integration via CloudFormation.
pub async fn generate_token(
client: &PlerionClient,
integration_id: &str,
integration_id: Option<&str>,
) -> Result<serde_json::Value, PlerionError> {
client
.execute(
client
.post("/v1/tenant/integrations/token")
.json(&serde_json::json!({ "integrationId": integration_id })),
)
.await
let req = client.post("/v1/tenant/integrations/token");
let req = match integration_id {
Some(id) => req.json(&serde_json::json!({ "integrationId": id })),
None => req,
};
client.execute(req).await
}
43 changes: 43 additions & 0 deletions src/api/models/aws.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};

/// Typed envelopes for the AWS integration endpoints, used by the onboarding
/// orchestrator. The raw `plerion aws ...` subcommands keep returning
/// `serde_json::Value` so their passthrough output is unchanged.

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalIdResponse {
pub data: Option<ExternalIdData>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalIdData {
pub external_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CfnTemplateResponse {
pub data: Option<CfnTemplateData>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CfnTemplateData {
#[serde(rename = "templateURL")]
pub template_url: Option<String>,
pub template_version: Option<String>,
/// Not returned by the API today; picked up automatically if/when the
/// tenant's Plerion-managed CWPP service account ID is exposed here.
pub service_account_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResponse {
pub data: Option<TokenData>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenData {
pub token: Option<String>,
}
1 change: 1 addition & 0 deletions src/api/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod alerts;
pub mod asset_groups;
pub mod assets;
pub mod audit_logs;
pub mod aws;
pub mod compliance;
pub mod findings;
pub mod iac;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/aws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub async fn run(args: &AwsArgs, config: &Config) -> anyhow::Result<()> {
output::render_json_value(&resp, config.output, config.query.as_deref())?;
}
AwsCommands::GenerateToken { integration_id } => {
let resp = generate_token(&client, integration_id).await?;
let resp = generate_token(&client, Some(integration_id)).await?;
output::render_json_value(&resp, config.output, config.query.as_deref())?;
}
}
Expand Down
132 changes: 131 additions & 1 deletion src/cli/integrations.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use clap::{Args, Subcommand};
use crate::api::{client::PlerionClient, endpoints::integrations::list_integrations};
use crate::config::Config;
use crate::onboard;
use crate::output;

#[derive(Args, Debug)]
Expand All @@ -12,6 +13,8 @@ pub struct IntegrationsArgs {
#[derive(Subcommand, Debug)]
pub enum IntegrationsCommands {
List(ListIntegrationsArgs),
/// Onboard a cloud account as a new integration
Add(AddArgs),
}

#[derive(Args, Debug)]
Expand All @@ -22,10 +25,83 @@ pub struct ListIntegrationsArgs {
#[arg(long)] pub all: bool,
}

#[derive(Args, Debug)]
pub struct AddArgs {
#[command(subcommand)]
pub provider: AddProvider,
}

#[derive(Subcommand, Debug)]
pub enum AddProvider {
/// Onboard an AWS account: preflight checks, CloudFormation deploy,
/// automatic registration with Plerion
Aws(AddAwsArgs),
}

#[derive(Args, Debug)]
pub struct AddAwsArgs {
/// AWS CLI profile for the target account (default: AWS default credential chain)
#[arg(long)]
pub aws_profile: Option<String>,

/// AWS region to deploy the stack in (IAM is global; any enabled region works)
#[arg(long)]
pub aws_region: Option<String>,

/// Abort unless the resolved AWS account matches (recommended with --yes)
#[arg(long)]
pub expect_account_id: Option<String>,

/// Skip interactive confirmation
#[arg(long, short = 'y')]
pub yes: bool,

/// CloudFormation stack name (must start with "Plerion-" unless --no-auto-update)
#[arg(long, default_value = onboard::DEFAULT_STACK_NAME)]
pub stack_name: String,

/// Do not create the Plerion auto-update role
#[arg(long)]
pub no_auto_update: bool,

/// KMS key access mode for workload scanning
#[arg(long, default_value = "ALL_KEYS", value_parser = ["ALL_KEYS", "SELECTED_KEYS"])]
pub kms_key_access_mode: String,

/// Plerion-managed CWPP service account ID (only needed when it cannot be
/// resolved automatically)
#[arg(long)]
pub service_account_id: Option<String>,

/// Proceed even if the account already has a Plerion integration or roles
#[arg(long)]
pub allow_existing: bool,

/// Treat IAM simulation denials as fatal (default: advisory — the
/// simulator has known false negatives with AWS Identity Center roles)
#[arg(long)]
pub strict_preflight: bool,

/// Run every preflight check, deploy nothing
#[arg(long)]
pub validate_only: bool,

/// Print the execution plan without any network calls
#[arg(long)]
pub dry_run: bool,

/// Seconds to wait for stack completion
#[arg(long, default_value = "1800")]
pub wait_timeout: u64,

#[arg(long, default_value = onboard::PLERION_ACCOUNT_ID_DEFAULT, hide = true)]
pub plerion_account_id: String,
}

pub async fn run(args: &IntegrationsArgs, config: &Config) -> anyhow::Result<()> {
let client = PlerionClient::new(config)?;
match &args.command {
IntegrationsCommands::List(a) => {
let client = PlerionClient::new(config)?;
if a.all {
let mut all_items = Vec::new();
let mut cursor: Option<String> = None;
Expand All @@ -41,6 +117,60 @@ pub async fn run(args: &IntegrationsArgs, config: &Config) -> anyhow::Result<()>
let resp = list_integrations(&client, Some(a.per_page), None, a.include_total).await?;
output::render_list(&resp.data, config.output, config.query.as_deref(), config.no_color)?;
}
Ok(())
}
IntegrationsCommands::Add(add) => match &add.provider {
AddProvider::Aws(a) => run_add_aws(a, config).await,
},
}
}

/// The host the deployed stack reports back to. Derived from the tenant
/// region, or from --endpoint-url when overridden (dev tenants).
fn plerion_url(config: &Config) -> String {
match &config.endpoint_url {
Some(url) => url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/')
.to_string(),
None => format!("{}.api.plerion.com", config.region),
}
}

async fn run_add_aws(args: &AddAwsArgs, config: &Config) -> anyhow::Result<()> {
let opts = onboard::OnboardOptions {
stack_name: args.stack_name.clone(),
auto_update: !args.no_auto_update,
kms_key_access_mode: args.kms_key_access_mode.clone(),
service_account_id: args.service_account_id.clone(),
expect_account_id: args.expect_account_id.clone(),
allow_existing: args.allow_existing,
strict_preflight: args.strict_preflight,
validate_only: args.validate_only,
yes: args.yes,
wait_timeout_secs: args.wait_timeout,
poll_interval_secs: 15,
plerion_account_id: args.plerion_account_id.clone(),
plerion_url: plerion_url(config),
tenant_label: config
.endpoint_url
.clone()
.unwrap_or_else(|| config.region.clone()),
};

if args.dry_run {
print!("{}", onboard::plan::render(&opts));
return Ok(());
}

let client = PlerionClient::new(config)?;
let aws = onboard::sdk::SdkAws::new(args.aws_profile.as_deref(), args.aws_region.as_deref()).await;
let mut ui = onboard::ui::TtyUi;

match onboard::run(&client, &aws, &mut ui, &opts).await? {
onboard::OnboardOutcome::Completed(result) | onboard::OnboardOutcome::Validated(result) => {
output::render_json_value(&result, config.output, config.query.as_deref())?;
}
}
Ok(())
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pub mod api;
pub mod cli;
pub mod config;
pub mod error;
pub mod onboard;
pub mod output;
9 changes: 8 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod api;
mod cli;
mod config;
mod error;
mod onboard;
mod output;

use clap::Parser;
Expand Down Expand Up @@ -68,6 +69,12 @@ async fn main() {

if let Err(e) = result {
eprintln!("Error: {e}");
std::process::exit(1);
// `integrations add aws` has a documented exit-code contract
// (2 preflight, 3 deploy, 4 already onboarded); everything else exits 1.
let code = e
.downcast_ref::<onboard::OnboardError>()
.map(|oe| oe.exit_code())
.unwrap_or(1);
std::process::exit(code);
}
}
Loading