diff --git a/Cargo.lock b/Cargo.lock index 64bdb0e..8531a28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -331,9 +331,9 @@ checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" [[package]] name = "cc" -version = "1.0.100" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c891175c3fb232128f48de6590095e59198bbeb8620c310be349bfc3afd12c7b" +checksum = "74b6a57f98764a267ff415d50a25e6e166f3831a5071af4995296ea97d210490" [[package]] name = "cfg-if" @@ -942,9 +942,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "log" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" dependencies = [ "value-bag", ] @@ -993,9 +993,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" dependencies = [ "memchr", ] @@ -1098,6 +1098,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "prettyplease" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.86" @@ -1289,6 +1299,17 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +[[package]] +name = "sentinel" +version = "0.1.1" +dependencies = [ + "serde", + "serde_json", + "url", + "waki", + "wit-bindgen 0.27.0", +] + [[package]] name = "serde" version = "1.0.203" @@ -1311,9 +1332,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.118" +version = "1.0.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4" +checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" dependencies = [ "itoa", "ryu", @@ -1419,7 +1440,7 @@ dependencies = [ "serde_json", "url", "waki", - "wit-bindgen", + "wit-bindgen 0.27.0", ] [[package]] @@ -1702,10 +1723,11 @@ dependencies = [ "anyhow", "http 1.1.0", "serde", + "serde_json", "serde_urlencoded", "url", "waki-macros", - "wit-bindgen", + "wit-bindgen 0.26.0", "wit-deps", ] @@ -1810,6 +1832,16 @@ dependencies = [ "leb128", ] +[[package]] +name = "wasm-encoder" +version = "0.212.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501940df4418b8929eb6d52f1aade1fdd15a5b86c92453cb696e3c906bd3fc33" +dependencies = [ + "leb128", + "wasmparser 0.212.0", +] + [[package]] name = "wasm-metadata" version = "0.209.1" @@ -1822,8 +1854,24 @@ dependencies = [ "serde_derive", "serde_json", "spdx", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.209.1", + "wasmparser 0.209.1", +] + +[[package]] +name = "wasm-metadata" +version = "0.212.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a1849fac257fd76c43268555e73d74848c8dff23975c238c2cbad61cffe5045" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder 0.212.0", + "wasmparser 0.212.0", ] [[package]] @@ -1852,6 +1900,19 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.212.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d28bc49ba1e5c5b61ffa7a2eace10820443c4b7d1c0b144109261d14570fdf8" +dependencies = [ + "ahash", + "bitflags 2.6.0", + "hashbrown", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.69" @@ -2054,8 +2115,18 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84376ff4f74ed07674a1157c0bd19e6627ab01fc90952a27ccefb52a24530f0" dependencies = [ - "wit-bindgen-rt", - "wit-bindgen-rust-macro", + "wit-bindgen-rt 0.26.0", + "wit-bindgen-rust-macro 0.26.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabce76bbb8938536c437da0c3e1d4dda9065453f72a68f797c0cb3d67356a28" +dependencies = [ + "wit-bindgen-rt 0.27.0", + "wit-bindgen-rust-macro 0.27.0", ] [[package]] @@ -2066,7 +2137,18 @@ checksum = "36d4706efb67fadfbbde77955b299b111dd096e6776d8c6561d92f6147941880" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.209.1", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43fbdd3497c471bbfb6973b1fb9ffe6949f158248cb43171d6f1cf3de7eaa3" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.212.0", ] [[package]] @@ -2078,6 +2160,15 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75956ff0a04a87ca0526b07199ce3b9baee899f2e4723b5b63aa296ab172ec52" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "wit-bindgen-rust" version = "0.26.0" @@ -2087,9 +2178,25 @@ dependencies = [ "anyhow", "heck", "indexmap", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.209.1", + "wit-bindgen-core 0.26.0", + "wit-component 0.209.1", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf509c4ef97b18ec0218741c8318706ac30ff16bc1731f990319a42bbbcfe8e3" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata 0.212.0", + "wit-bindgen-core 0.27.0", + "wit-component 0.212.0", ] [[package]] @@ -2102,8 +2209,23 @@ dependencies = [ "proc-macro2", "quote", "syn", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.26.0", + "wit-bindgen-rust 0.26.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d6f2e025e38395d71fc1bf064e581b2ad275ce322d6f8d87ddc5e76a7b8c42" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core 0.27.0", + "wit-bindgen-rust 0.27.0", ] [[package]] @@ -2119,10 +2241,29 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "wasm-encoder 0.209.1", + "wasm-metadata 0.209.1", + "wasmparser 0.209.1", + "wit-parser 0.209.1", +] + +[[package]] +name = "wit-component" +version = "0.212.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed5b0f9fc3d6424787d2a49e1142bf954ae4f26ee891992c144f0cfd68c4b7f" +dependencies = [ + "anyhow", + "bitflags 2.6.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.212.0", + "wasm-metadata 0.212.0", + "wasmparser 0.212.0", + "wit-parser 0.212.0", ] [[package]] @@ -2165,7 +2306,25 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.209.1", +] + +[[package]] +name = "wit-parser" +version = "0.212.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceeb0424aa8679f3fcf2d6e3cfa381f3d6fa6179976a2c05a6249dd2bb426716" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.212.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3975fcf..ed642b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,12 +35,13 @@ panic = "abort" strip = "debuginfo" [workspace] -members = [ +members = [ + "sentinel", "splunk" ] [workspace.dependencies] -wit-bindgen = "0.26" +wit-bindgen = "0.27" serde = "1.0" serde_json = "1.0" waki = "0.3" diff --git a/README.md b/README.md index 17b6fdb..8873379 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,4 @@ With LogCraft CLI, you can easily deploy your security detections into your SIEM ## Plugins - [Splunk](./splunk) +- [Microsoft Azure Sentinel](./sentinel) diff --git a/licenserc.toml b/licenserc.toml index 2ff4f34..3b0a93f 100644 --- a/licenserc.toml +++ b/licenserc.toml @@ -13,7 +13,11 @@ strictCheck = true excludes = [ ".github/workflows/**", - "Cargo.*" + "Cargo.*", + + # Samples folder + "samples/python/myplugin/client", + "pyproject.toml", ] [git] diff --git a/sentinel/Cargo.toml b/sentinel/Cargo.toml new file mode 100644 index 0000000..f1110e7 --- /dev/null +++ b/sentinel/Cargo.toml @@ -0,0 +1,26 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 + +[package] +name = "sentinel" +description = "LogCraft CLI Sentinel plugin" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true +readme.workspace = true +categories.workspace = true +keywords.workspace = true + +[dependencies] +wit-bindgen.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +url.workspace = true +waki = { workspace = true, features = ["json"] } + +[lib] +crate-type = ["cdylib"] \ No newline at end of file diff --git a/sentinel/package/rule.k b/sentinel/package/rule.k new file mode 100644 index 0000000..9e6ffcd --- /dev/null +++ b/sentinel/package/rule.k @@ -0,0 +1,254 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 + +schema Rule: + """Represent scheduled alert rule. + + Attributes + ---------- + kind: str, 'Scheduled', required + The alert rule kind. + + properties: Properties, required + The display name for alerts created by this alert rule. + + etag: str, optional + Etag of the azure resource + ruleId: str, optional + ID of the rule. The global rule name is used if not specified. + """ + # Mandatory + kind: str = "Scheduled" + properties: Properties + + # Optional + etag?: str + ruleId?: str + + +schema Properties: + """ + enabled: bool, required + Determines whether this alert rule is enabled or disabled. + + query: str, required + The query that creates alerts for this rule. + + queryFrequency: str, required + The frequency (in ISO 8601 duration format) for this alert rule to run. + + queryPeriod: str, required + The period (in ISO 8601 duration format) that this alert rule looks at. + + severity: AlertSeverity, required + The severity for alerts created by this alert rule. + + suppressionDuration: str, required + The suppression (in ISO 8601 duration format) to wait since last time this alert rule been triggered. + + suppressionEnabled: bool, required + Determines whether the suppression for this alert rule is enabled or disabled. + + triggerOperator: TriggerOperator, required + The operation against the threshold that triggers alert rule. + + triggerThreshold: int, required + The threshold triggers this alert rule. + + alertDetailsOverride: AlertDetailsOverride + The alert details override settings + + alertRuleTemplateName: str + The Name of the alert rule template used to create this rule. + + customDetails: object + Dictionary of string key-value pairs of columns to be attached to the alert + + description: str + The description of the alert rule. + + entityMappings: [entityMappings] = [] + Array of the entity mappings of the alert rule + + eventGroupingSettings: eventGroupingSettings + The event grouping settings. + + incidentConfiguration: incidentConfiguration + The settings of the incidents that created from alerts triggered by this analytics rule + + tactics: [AttackTactic] + The tactics of the alert rule + + techniques: str[] + The techniques of the alert rule + + templateVersion: str + The version of the alert rule template used to create this rule - in format , where all are numbers, for example 0 <1.0.2> + + displayName: str, optional + The display name for alerts created by this alert rule. The global rule name is used if not specified. + """ + # Mandatory Parameters + severity: AlertSeverity + query: str + queryFrequency: str = "PT5H" + queryPeriod: str = "PT5H" + suppressionDuration: str = "PT5H" + suppressionEnabled: bool = False + enabled: bool = True + triggerOperator: TriggerOperator = "GreaterThan" + triggerThreshold: int = 0 + + # Optional Parameters + alertDetailsOverride?: AlertDetailsOverride + alertRuleTemplateName?: str + customDetails?: {str: any} + description?: str + entityMappings?: [EntityMapping] = [] + eventGroupingSettings?: EventGroupingSettings + incidentConfiguration?: IncidentConfiguration + tactics?: [AttackTactic] + techniques?: [str] + templateVersion?: str + displayName?: str + +type AlertSeverity = "High" | "Informational" | "Low" | "Medium" + +type AttackTactic = "Collection" | "CommandAndControl" | "CredentialAccess" | "DefenseEvasion" | "Discovery" | "Execution" | "Exfiltration" | "Impact" | "ImpairProcessControl" | "InhibitResponseFunction" | "InitialAccess" | "LateralMovement" | "Persistence" | "PreAttack" | "PrivilegeEscalation" | "Reconnaissance" | "ResourceDevelopment" + +type TriggerOperator = "Equal" | "GreaterThan" | "LessThan" | "NotEqual" + +schema AlertDetailsOverride: + """ + alertDescriptionFormat: str + The format containing columns name(s) to override the alert description + alertDisplayNameFormat: str + The format containing columns name(s) to override the alert name + alertDynamicProperties: [AlertPropertyMapping] + List of additional dynamic properties to override + alertSeverityColumnName: str + The column name to take the alert severity from + alertTacticsColumnName: str + The column name to take the alert tactics from + """ + alertDescriptionFormat: str + alertDisplayNameFormat: str + alertDynamicProperties: [AlertPropertyMapping] = [] + alertSeverityColumnName: str + alertTacticsColumnName: str + + +type AlertProperty = "AlertLink" | "ConfidenceLevel" | "ConfidenceScore" | "ExtendedLinks" | "ProductComponentName" | "ProductName" | "ProviderName" | "RemediationSteps" | "Techniques" + +schema AlertPropertyMapping: + """ + Attributes + ---------- + alertProperty: AlertProperty, required + The V3 alert property + value: str, required + The column name to use to override this property + """ + alertProperty: AlertProperty + value: str + + +type EntityMappingType = "Account" | "AzureResource" | "CloudApplication" | "DNS" | "File" | "FileHash" | "Host" | "IP" | "MailCluster" | "MailMessage" | "Mailbox" | "Malware" | "Process" | "RegistryKey" | "RegistryValue" | "SecurityGroup" | "SubmissionMail" | "URL" + +schema FieldMapping: + """ + Attributes + ---------- + columnName: str, required + The column name to be mapped to the identifier + identifier: str, required + The V3 identifier of the entity + + """ + columnName: str + identifier: str + +schema EntityMapping: + """ + Attributes + ---------- + entityType: EntityMappingType, required + The V3 alert property + fieldMappings: [FieldMapping], required + The column name to use to override this property + """ + entityType: EntityMappingType + fieldMappings: [FieldMapping] + + +schema EventGroupingAggregationKind: + """ + Attributes + ---------- + AlertPerResult: str, required + SingleAlert: str, required + """ + AlertPerResult: str + SingleAlert: str + +schema EventGroupingSettings: + """ + Attributes + ---------- + aggregationKind: EventGroupingAggregationKind, required + The event grouping aggregation kinds + """ + aggregationKind: EventGroupingAggregationKind + +schema IncidentConfiguration: + """ + Attributes + ---------- + createIncident: bool, required + Create incidents from alerts triggered by this analytics rule + groupingConfiguration: GroupingConfiguration, required + Set how the alerts that are triggered by this analytics rule, are grouped into incidents + """ + createIncident: bool + groupingConfiguration: GroupingConfiguration + +schema GroupingConfiguration: + """ + Attributes + ---------- + enabled: bool, required + Grouping enabled + groupByAlertDetails: [AlertDetail] = [] + A list of alert details to group by (when matchingMethod is Selected) + groupByCustomDetails: [str] = [] + A list of custom details keys to group by (when matchingMethod is Selected). Only keys defined in the current alert rule may be used. + groupByEntities: [EntityMappingType] = [] + A list of entity types to group by (when matchingMethod is Selected). Only entities defined in the current alert rule may be used. + lookbackDuration: str + Limit the group to alerts created within the lookback duration (in ISO 8601 duration format) + matchingMethod: MatchingMethod + Grouping matching method. When method is Selected at least one of groupByEntities, groupByAlertDetails, groupByCustomDetails must be provided and not empty. + reopenClosedIncident: bool + Re-open closed matching incidents + """ + enabled: bool + groupByAlertDetails: [AlertDetail] = [] + groupByCustomDetails: [str] = [] + groupByEntities: [EntityMappingType] = [] + lookbackDuration: str + matchingMethod: MatchingMethod + reopenClosedIncident: bool + +type MatchingMethod = "AllEntities" | "AnyAlert" | "Selected" + +schema AlertDetail: + """ + Attributes + ---------- + displayName: str, required + Alert display name + Severity: AlertSeverity, required + Alert severity + """ + displayName: str + Severity: AlertSeverity \ No newline at end of file diff --git a/sentinel/package/settings.k b/sentinel/package/settings.k new file mode 100644 index 0000000..9605aaa --- /dev/null +++ b/sentinel/package/settings.k @@ -0,0 +1,51 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 + +import regex + +schema Configuration: + """Sentinel Configuration + + Attributes + ---------- + client_id: str, required + Azure client id + client_secret: str, required + Azure client secret + tenant_id: str, required + Azure tenant id + api_version: str, optional + Sentinel API version + resource_group_name: str, required + The name of the resource group. The name is case insensitive + workspace_name: str, required + The name of the workspace + subscription_id: str, required + The ID of the target subscription + timeout: int, optional + Timeout in seconds + """ + # Mandatory Parameters + # Client ID + @info(sensitive="true") + client_id: str = "MY_AZURE_CLIENT_ID" + # Client Secret + @info(sensitive="true") + client_secret: str = "MY_AZURE_CLIENT_SECRET" + # Tenant ID + tenant_id: str = "my-tenant-id" + # Sentinel API version + api_version?: str = "2023-11-01" + # ResourceGroup name + resource_group_name: str + # Workspace name + workspace_name: str + # Target subscription ID + subscription_id: str + # Timeout + timeout?: int = 60 + + check: + regex.match(client_id, "^[A-Za-z0-9][A-Za-z0-9-]+[A-Za-z0-9]"), "Incorrect client_id, must be kebab-case formatted" + regex.match(tenant_id, "^[A-Za-z0-9][A-Za-z0-9-]+[A-Za-z0-9]"), "Incorrect tenant_id, must be kebab-case formatted" + regex.match(subscription_id, "^[A-Za-z0-9][A-Za-z0-9-]+[A-Za-z0-9]"), "Incorrect subscription_id, must be kebab-case formatted" \ No newline at end of file diff --git a/sentinel/src/lib.rs b/sentinel/src/lib.rs new file mode 100644 index 0000000..2ae4c2d --- /dev/null +++ b/sentinel/src/lib.rs @@ -0,0 +1,336 @@ +// Copyright (c) 2023 LogCraft, SAS. +// SPDX-License-Identifier: MPL-2.0 + +mod bindings { + wit_bindgen::generate!({ + path: "../wit", + world: "logcraft:lgc/plugins" + }); +} +use bindings::{ + export, + exports::logcraft::lgc::plugin::{Guest, Metadata}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::HashMap, time::Duration}; +use url::Url; +use waki::{Client, Method, RequestBuilder}; + +const SETTINGS: &str = include_str!("../package/settings.k"); +const DETECTION_SCHEMA: &str = include_str!("../package/rule.k"); + +#[derive(Serialize, Deserialize)] +struct Sentinel { + client_id: String, + client_secret: String, + tenant_id: String, + api_version: Option, + resource_group_name: String, + subscription_id: String, + workspace_name: String, + timeout: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct SentinelRule { + #[serde(skip_serializing_if = "Option::is_none")] + rule_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + etag: Option, + kind: RuleType, + properties: HashMap, +} + +impl SentinelRule { + fn default_properties(&mut self) -> &Self { + // Default ISO 8601 duration format + let default_duration = Value::String("PT5H".to_string()); + let defaults = [ + ("queryFrequency", default_duration.clone()), + ("queryPeriod", default_duration.clone()), + ("suppressionDuration", default_duration), + ("suppressionEnabled", Value::Bool(false)), + ("enabled", Value::Bool(true)), + ("triggerOperator", Value::String("GreaterThan".to_string())), + ("triggerThreshold", Value::Number(0.into())), + ]; + + for (fname, fvalue) in defaults { + self.properties.entry(fname.to_string()).or_insert(fvalue); + } + + self + } +} + +#[derive(Serialize, Deserialize, Clone)] +enum RuleType { + Scheduled, +} + +#[derive(Deserialize)] +struct CloudError { + error: ErrorBody, +} + +#[derive(Deserialize)] +struct ErrorBody { + code: String, + message: String, +} + +impl CloudError { + fn from_slices(body: Vec) -> String { + match serde_json::from_slice::(&body) { + Ok(resp) => format!("{}: {}", resp.error.code, resp.error.message), + Err(_) => String::from_utf8(body).unwrap(), + } + } +} + +#[derive(Deserialize)] +struct AzureAuthz { + access_token: String, +} + +const AZURE_AUTH_DEFAULT_ENDPOINT: &str = "https://login.microsoftonline.com"; +const AZURE_MGT_ENDPOINT: &str = "https://management.azure.com"; + +impl Sentinel { + fn get_credentials(&self) -> Result { + let req = Client::new() + .post(&format!( + "{AZURE_AUTH_DEFAULT_ENDPOINT}/{}/oauth2/token", + self.tenant_id + )) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", &self.client_id), + ("client_secret", &self.client_secret), + ("resource", AZURE_MGT_ENDPOINT), + ]); + + match req.send() { + Ok(resp) => match resp.status_code() { + 200 => { + let resp: AzureAuthz = serde_json::from_slice( + &resp.body().expect("azure authz invalid UTF-8 response"), + ) + .map_err(|e| format!("unable to parse azure authz response: {e}"))?; + + Ok(resp.access_token) + } + _ => Err(CloudError::from_slices( + resp.body() + .map_err(|e| format!("invalid UTF-8 response: {e}"))?, + )), + }, + Err(e) => Err(format!("{}", e)), + } + } + + fn client(&self, method: Method, rule_id: &str) -> Result { + let url = Url::parse(&format!( + "{AZURE_MGT_ENDPOINT}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.OperationalInsights/workspaces/{}/providers/Microsoft.SecurityInsights/alertRules/{}", + &self.subscription_id, + &self.resource_group_name, + &self.workspace_name, + rule_id + )) + .map_err(|e| e.to_string())?; + + let bearer_token = self.get_credentials()?; + let client = Client::new() + .request(method, url.as_str()) + .connect_timeout(Duration::from_secs(self.timeout.unwrap_or(60))) + .header("Authorization", format!("Bearer {}", &bearer_token,)) + .query(&[( + "api-version", + self.api_version.clone().unwrap_or("2023-11-01".to_string()), + )]); + + Ok(client) + } + + fn parse_configuration(config: &str) -> Result { + serde_json::from_str::(config) + .map_err(|err| format!("unable to parse configuration: {}", err)) + } +} + +impl Guest for Sentinel { + /// Retrieve plugin metadata + fn load() -> Metadata { + Metadata { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + author: env!("CARGO_PKG_AUTHORS").to_string(), + description: env!("CARGO_PKG_DESCRIPTION").to_string(), + } + } + + /// Retrieve plugin settings + fn settings() -> String { + SETTINGS.to_string() + } + + /// Retrieve plugin detection schema + fn schema() -> String { + DETECTION_SCHEMA.to_string() + } + + /// Create Sentinel detection rule + fn create(config: String, name: String, params: String) -> Result, String> { + let mut rule: SentinelRule = + serde_json::from_str(¶ms).map_err(|e| format!("unable to parse rule: {e}"))?; + + rule.properties + .insert("displayName".to_string(), Value::String(name.clone())); + + let rule_id = if let Some(rule_id) = &rule.rule_id { + rule_id + } else { + &name + }; + + let config = Sentinel::parse_configuration(&config)?; + let client = config + .client(Method::Put, rule_id)? + .header("Content-Type", "application/json") + .json(rule.default_properties()); + + match client.send() { + Ok(resp) => match resp.status_code() { + 200 | 201 => Ok(Some(String::default())), + _ => Err(CloudError::from_slices( + resp.body() + .map_err(|e| format!("invalid UTF-8 response: {e}"))?, + )), + }, + Err(e) => Err(e.to_string()), + } + } + + /// Create Sentinel detection rule + fn read(config: String, name: String, params: String) -> Result, String> { + let mut rule: SentinelRule = + serde_json::from_str(¶ms).map_err(|e| format!("unable to parse rule: {e}"))?; + + let rule_id = if let Some(rule_id) = &rule.rule_id { + rule_id + } else { + &name + }; + + let config = Sentinel::parse_configuration(&config)?; + let client = config.client(Method::Get, rule_id)?; + + match client.send() { + Ok(resp) => match resp.status_code() { + 200 => match resp.body() { + Ok(body) => { + let resp: SentinelRule = serde_json::from_slice(&body) + .map_err(|e| format!("unable to parse response: {e}"))?; + + let filtered: HashMap = rule + .properties + .iter() + .filter_map(|(k, _)| { + resp.properties + .get_key_value(k) + .map(|(k, v)| (k.clone(), v.clone())) + }) + .collect(); + + rule.properties = filtered; + let json = serde_json::to_string(&rule) + .map_err(|e| format!("unable to serialize response: {e}"))?; + Ok(Some(json)) + } + Err(e) => Err(format!("response: invalid UTF-8 sequence, {e}")), + }, + 404 => Ok(None), + _ => Err(CloudError::from_slices( + resp.body() + .map_err(|e| format!("invalid UTF-8 response: {e}"))?, + )), + }, + Err(e) => Err(e.to_string()), + } + } + + /// Create Sentinel detection rule + fn update(config: String, name: String, params: String) -> Result, String> { + // Sentinal uses the same method for create and udpdate + Sentinel::create(config, name, params) + } + + /// Delete Sentinel detection rule + fn delete(config: String, name: String, params: String) -> Result, String> { + let context: SentinelRule = + serde_json::from_str(¶ms).map_err(|e| format!("unable to parse rule: {e}"))?; + + let rule_id = if let Some(rule_id) = context.rule_id { + rule_id + } else { + name + }; + + let config = Sentinel::parse_configuration(&config)?; + let client = config.client(Method::Delete, &rule_id)?; + + match client.send() { + Ok(resp) => match resp.status_code() { + 200 => Ok(Some(String::default())), + 204 => Ok(None), + _ => Err(CloudError::from_slices( + resp.body() + .map_err(|e| format!("invalid UTF-8 response: {e}"))?, + )), + }, + Err(e) => Err(e.to_string()), + } + } + + /// Ping service + fn ping(config: String) -> Result { + let config = Sentinel::parse_configuration(&config)?; + + // Check access to workspace + let workspace_endpoint = format!( + "{AZURE_MGT_ENDPOINT}/subscriptions/{}/resourcegroups/{}/providers/Microsoft.OperationalInsights/workspaces/{}/providers/Microsoft.SecurityInsights/alertRules", + config.subscription_id, + config.resource_group_name, + config.workspace_name, + ); + + match Client::new() + .get(&workspace_endpoint) + .header( + "Authorization", + format!("Bearer {}", config.get_credentials()?), + ) + .query(&[( + "api-version", + config + .api_version + .clone() + .unwrap_or("2023-11-01".to_string()), + )]) + .send() + { + Ok(resp) => match resp.status_code() { + 200 => Ok(true), + _ => Err(CloudError::from_slices( + resp.body() + .map_err(|e| format!("invalid UTF-8 response: {e}"))?, + )), + }, + Err(e) => Err(e.to_string()), + } + } +} + +export!(Sentinel with_types_in bindings);