From dc0981920fded302e14f4507c040872f4bfd4dc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:12:01 +0000 Subject: [PATCH 1/2] feat(security): add findings mute and rules bulk-convert commands - pup security findings mute: wraps MuteSecurityFindings (stable, SDK #1519/#1660) - pup security rules bulk-convert: wraps BulkConvertExistingSecurityMonitoringRules (#1675) Co-Authored-By: Claude --- docs/COMMANDS.md | 2 + src/commands/security.rs | 138 ++++++++++++++++++++++++++++++++++++++- src/main.rs | 20 ++++++ 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0948138..fa1172c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -161,6 +161,8 @@ pup infrastructure hosts list ### Security & Compliance - **security** - Security monitoring (rules, signals, findings, content-packs, risk-scores) + - `pup security findings mute --file ` — Mute or unmute up to 100 findings (stable, SDK #1519/#1660) + - `pup security rules bulk-convert --file ` — Bulk convert existing rules to Terraform ZIP archive (SDK #1675) - **static-analysis** - Code security (custom-rulesets, custom-rules) - **audit-logs** - Audit trail (list, search) - **data-governance** - Sensitive data scanning (scanner-rules list) diff --git a/src/commands/security.rs b/src/commands/security.rs index 3f7baf6..72fa945 100644 --- a/src/commands/security.rs +++ b/src/commands/security.rs @@ -18,9 +18,10 @@ use datadog_api_client::datadogV2::api_security_monitoring::{ use datadog_api_client::datadogV2::model::{ ApplicationSecurityWafCustomRuleCreateRequest, ApplicationSecurityWafCustomRuleUpdateRequest, ApplicationSecurityWafExclusionFilterCreateRequest, - ApplicationSecurityWafExclusionFilterUpdateRequest, RestrictionPolicyUpdateRequest, - SecurityMonitoringRuleBulkExportAttributes, SecurityMonitoringRuleBulkExportData, - SecurityMonitoringRuleBulkExportDataType, SecurityMonitoringRuleBulkExportPayload, + ApplicationSecurityWafExclusionFilterUpdateRequest, MuteFindingsRequest, + RestrictionPolicyUpdateRequest, SecurityMonitoringRuleBulkExportAttributes, + SecurityMonitoringRuleBulkExportData, SecurityMonitoringRuleBulkExportDataType, + SecurityMonitoringRuleBulkExportPayload, SecurityMonitoringRuleConvertBulkPayload, SecurityMonitoringRuleConvertPayload, SecurityMonitoringRuleSort, SecurityMonitoringSignalListRequest, SecurityMonitoringSignalListRequestFilter, SecurityMonitoringSignalListRequestPage, SecurityMonitoringSignalsSort, @@ -288,6 +289,21 @@ pub async fn findings_search(cfg: &Config, query: Option, limit: i64) -> formatter::output(cfg, &resp) } +// ---- Mute Findings ---- + +/// Mute or unmute security findings (stable, SDK #1519/#1660). +/// Accepts up to 100 finding IDs per request. The `--file` must contain a +/// JSON body shaped as `MuteFindingsRequest` (see Datadog docs). +pub async fn findings_mute(cfg: &Config, file: &str) -> Result<()> { + let body: MuteFindingsRequest = util::read_json_file(file)?; + let api = crate::make_api!(SecurityMonitoringAPI, cfg); + let resp = api + .mute_security_findings(body) + .await + .map_err(|e| anyhow::anyhow!("failed to mute findings: {e:?}"))?; + formatter::output(cfg, &resp) +} + // ---- Bulk Export ---- pub async fn rules_bulk_export(cfg: &Config, rule_ids: Vec) -> Result<()> { @@ -330,6 +346,22 @@ pub async fn rules_to_terraform(cfg: &Config, file: &str) -> Result<()> { formatter::output(cfg, &resp) } +/// Bulk convert existing security monitoring rules to Terraform (SDK #1675). +/// The `--file` must contain a JSON body shaped as +/// `SecurityMonitoringRuleConvertBulkPayload`. Returns a ZIP archive written +/// to stdout (pipe to a file if you want to save it). +pub async fn rules_bulk_convert(cfg: &Config, file: &str) -> Result<()> { + let body: SecurityMonitoringRuleConvertBulkPayload = util::read_json_file(file)?; + let api = crate::make_api!(SecurityMonitoringAPI, cfg); + let bytes = api + .bulk_convert_existing_security_monitoring_rules(body) + .await + .map_err(|e| anyhow::anyhow!("failed to bulk convert security rules: {e:?}"))?; + let output = String::from_utf8_lossy(&bytes); + println!("{output}"); + Ok(()) +} + pub async fn terraform_export(cfg: &Config, resource_type: &str, resource_id: &str) -> Result<()> { let rt = parse_terraform_resource_type(resource_type)?; let api = crate::make_api!(SecurityMonitoringAPI, cfg); @@ -1099,4 +1131,104 @@ mod tests { .to_string() .contains("invalid --sort value")); } + + #[tokio::test] + async fn test_findings_mute_ok() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let tmp = write_temp_json( + "pup_test_findings_mute.json", + r#"{"data":{"type":"mute","attributes":{"mute":{"is_muted":true,"reason":"FALSE_POSITIVE"}},"relationships":{"findings":{"data":[]}}}}"#, + ); + let _mock = mock_any( + &mut server, + "PATCH", + r#"{"data":{"id":"mute-job-1","type":"mute_findings_response"}}"#, + ) + .await; + let result = super::findings_mute(&cfg, tmp.to_str().unwrap()).await; + assert!(result.is_ok(), "findings_mute failed: {:?}", result.err()); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_findings_mute_error() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let tmp = write_temp_json( + "pup_test_findings_mute_err.json", + r#"{"data":{"type":"mute","attributes":{"mute":{"is_muted":true,"reason":"FALSE_POSITIVE"}},"relationships":{"findings":{"data":[]}}}}"#, + ); + let _mock = server + .mock("PATCH", mockito::Matcher::Any) + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::findings_mute(&cfg, tmp.to_str().unwrap()).await; + assert!(result.is_err(), "expected error for 403 response"); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_rules_bulk_convert_ok() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let tmp = write_temp_json( + "pup_test_rules_bulk_convert.json", + r#"{"data":{"type":"security_monitoring_rules_convert_bulk","attributes":{"ruleIds":["abc-123"]}}}"#, + ); + let zip_bytes: &[u8] = b"PK\x03\x04fake-zip-bytes"; + let _mock = server + .mock("POST", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/zip") + .with_body(zip_bytes) + .create_async() + .await; + let result = super::rules_bulk_convert(&cfg, tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "rules_bulk_convert failed: {:?}", + result.err() + ); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_rules_bulk_convert_error() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let tmp = write_temp_json( + "pup_test_rules_bulk_convert_err.json", + r#"{"data":{"type":"security_monitoring_rules_convert_bulk","attributes":{"ruleIds":["bad"]}}}"#, + ); + let _mock = server + .mock("POST", mockito::Matcher::Any) + .with_status(400) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["Bad Request"]}"#) + .create_async() + .await; + let result = super::rules_bulk_convert(&cfg, tmp.to_str().unwrap()).await; + assert!(result.is_err(), "expected error for 400 response"); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } } diff --git a/src/main.rs b/src/main.rs index 8fbdede..151bf11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4689,6 +4689,15 @@ enum SecurityRuleActions { #[arg(long, help = "JSON file with the rule conversion payload (required)")] file: String, }, + /// Bulk convert existing rules to Terraform (returns a ZIP archive) + #[command(name = "bulk-convert")] + BulkConvert { + #[arg( + long, + help = "JSON file with SecurityMonitoringRuleConvertBulkPayload body (required)" + )] + file: String, + }, } #[derive(Subcommand)] @@ -4759,6 +4768,11 @@ enum SecurityFindingActions { #[arg(long, default_value_t = 100)] limit: i64, }, + /// Mute or unmute security findings (up to 100 per request) + Mute { + #[arg(long, help = "JSON file with MuteFindingsRequest body (required)")] + file: String, + }, } #[derive(Subcommand)] @@ -11674,6 +11688,9 @@ async fn main_inner() -> anyhow::Result<()> { SecurityRuleActions::ToTerraform { file } => { commands::security::rules_to_terraform(&cfg, &file).await?; } + SecurityRuleActions::BulkConvert { file } => { + commands::security::rules_bulk_convert(&cfg, &file).await?; + } }, SecurityActions::Signals { action } => match action { SecuritySignalActions::List { @@ -11709,6 +11726,9 @@ async fn main_inner() -> anyhow::Result<()> { commands::security::findings_analyze(&cfg, &query, &from, &to, limit) .await?; } + SecurityFindingActions::Mute { file } => { + commands::security::findings_mute(&cfg, &file).await?; + } }, SecurityActions::ContentPacks { action } => match action { SecurityContentPackActions::List => { From 23748eef02761bc2a74fe3b97aedca3473821bc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 09:49:42 +0000 Subject: [PATCH 2/2] fix(auth): request security_monitoring_findings_write scope at login The new `pup security findings mute` command calls mute_security_findings, which the Datadog API gates behind the security_monitoring_findings_write authorization scope. default_scopes() only requested security_monitoring_findings_read, so OAuth2 users would get a 403 on mute while API/app-key users (with full app-key permissions) succeeded. Add security_monitoring_findings_write to default_scopes() so `pup auth login` requests it. Left out of read_only_scopes() since it is a write scope. Co-Authored-By: Claude --- src/auth/types.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/auth/types.rs b/src/auth/types.rs index f86dfcd..168e779 100644 --- a/src/auth/types.rs +++ b/src/auth/types.rs @@ -209,6 +209,7 @@ pub fn default_scopes() -> Vec<&'static str> { "security_monitoring_filters_read", "security_monitoring_filters_write", "security_monitoring_findings_read", + "security_monitoring_findings_write", "security_monitoring_rules_read", "security_monitoring_rules_write", "security_monitoring_signals_read",