kql_panopticon/pack/
types.rs

1//! Pack type definitions
2//!
3//! Contains all supporting types for pack definitions including steps,
4//! inputs, HTTP/file configurations, and reporting/scoring options.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Input value type
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum InputType {
13    /// Single string value (default)
14    #[default]
15    String,
16    /// Array of strings (comma-separated input, quoted in substitution)
17    Array,
18}
19
20/// User-provided input definition
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Input {
23    /// Input name (used in {{inputs.name}})
24    pub name: String,
25
26    /// Input type (string or array)
27    #[serde(default, rename = "type")]
28    pub input_type: InputType,
29
30    /// Human-readable label
31    #[serde(default)]
32    pub label: Option<String>,
33
34    /// Description
35    #[serde(default)]
36    pub description: Option<String>,
37
38    /// Default value
39    #[serde(default)]
40    pub default: Option<String>,
41
42    /// Whether input is required
43    #[serde(default = "default_true")]
44    pub required: bool,
45
46    /// Example value for validation (used when validating queries with substitution)
47    #[serde(default)]
48    pub example: Option<String>,
49}
50
51fn default_true() -> bool {
52    true
53}
54
55/// Parse user input value using JSON syntax
56///
57/// Accepts JSON strings and arrays, rejects all other JSON types:
58/// - **JSON string**: `"value"` → normalized to `value`
59/// - **JSON array**: `["item1", "item2"]` → normalized to `item1,item2`
60/// - **Raw value**: `value` → kept as-is (for simple identifiers/numbers)
61///
62/// This normalization allows the value to be interpreted correctly based on
63/// the pack's `InputType` definition during execution:
64/// - `InputType::String` uses the value as-is
65/// - `InputType::Array` splits on commas and quotes each element
66///
67/// # Examples
68///
69/// ```
70/// use kql_panopticon_core::pack::parse_input_value;
71///
72/// // JSON array
73/// assert_eq!(
74///     parse_input_value(r#"["8.8.8.8", "1.1.1.1"]"#).unwrap(),
75///     "8.8.8.8,1.1.1.1"
76/// );
77///
78/// // Empty array
79/// assert_eq!(
80///     parse_input_value(r#"[]"#).unwrap(),
81///     ""
82/// );
83///
84/// // JSON string
85/// assert_eq!(
86///     parse_input_value(r#""some value""#).unwrap(),
87///     "some value"
88/// );
89///
90/// // Raw value (non-JSON fallback)
91/// assert_eq!(
92///     parse_input_value("P7D").unwrap(),
93///     "P7D"
94/// );
95///
96/// // Note: Valid JSON numbers/booleans are rejected
97/// assert!(parse_input_value("7").is_err());
98/// assert!(parse_input_value("true").is_err());
99/// ```
100pub fn parse_input_value(input: &str) -> Result<String, String> {
101    use serde_json::Value;
102
103    let trimmed = input.trim();
104
105    // Empty input
106    if trimmed.is_empty() {
107        return Err("Input value cannot be empty".to_string());
108    }
109
110    // Try to parse as JSON first
111    match serde_json::from_str::<Value>(trimmed) {
112        Ok(Value::String(s)) => {
113            // JSON string - extract the value
114            Ok(s)
115        }
116        Ok(Value::Array(arr)) => {
117            // JSON array - extract string items and join with commas
118            let mut items = Vec::new();
119            for (idx, item) in arr.iter().enumerate() {
120                match item {
121                    Value::String(s) => items.push(s.clone()),
122                    _ => {
123                        return Err(format!(
124                            "Array item at index {} must be a string, found: {}",
125                            idx,
126                            match item {
127                                Value::Null => "null",
128                                Value::Bool(_) => "boolean",
129                                Value::Number(_) => "number",
130                                Value::Array(_) => "nested array",
131                                Value::Object(_) => "object",
132                                Value::String(_) => unreachable!(),
133                            }
134                        ));
135                    }
136                }
137            }
138            Ok(items.join(","))
139        }
140        Ok(other) => {
141            // Other JSON types not supported
142            Err(format!(
143                "Input must be a JSON string or array, found: {}",
144                match other {
145                    Value::Null => "null",
146                    Value::Bool(_) => "boolean",
147                    Value::Number(_) => "number",
148                    Value::Object(_) => "object",
149                    _ => unreachable!(),
150                }
151            ))
152        }
153        Err(_) => {
154            // Not valid JSON - treat as raw value
155            // Allow simple unquoted values like: 7, simple-name, 8.8.8.8,1.1.1.1
156            Ok(trimmed.to_string())
157        }
158    }
159}
160
161/// A single execution step
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Step {
164    /// Step name (unique identifier)
165    pub name: String,
166
167    /// Step type (kql or http)
168    #[serde(default, rename = "type")]
169    pub step_type: StepType,
170
171    /// KQL query (for KQL steps)
172    #[serde(default)]
173    pub query: Option<String>,
174
175    /// Timespan for the query (e.g., "P7D")
176    #[serde(default)]
177    pub timespan: Option<String>,
178
179    /// HTTP request configuration (for HTTP steps)
180    #[serde(default)]
181    pub request: Option<HttpRequest>,
182
183    /// HTTP response field mapping (for HTTP steps)
184    #[serde(default)]
185    pub response: Option<HttpResponse>,
186
187    /// File source configuration (for File steps)
188    #[serde(default)]
189    pub source: Option<FileSource>,
190
191    /// Rate limiting for HTTP steps
192    #[serde(default)]
193    pub rate_limit: Option<RateLimitConfig>,
194
195    /// Error handling behavior
196    #[serde(default)]
197    pub on_error: Option<OnError>,
198
199    /// Steps this step depends on (must complete first)
200    #[serde(default)]
201    pub depends_on: Vec<String>,
202
203    /// Condition for executing this step
204    #[serde(default)]
205    pub when: Option<String>,
206
207    /// Step-level options
208    #[serde(default)]
209    pub options: Option<StepOptions>,
210
211    /// Example values for validation (maps variable refs to example values)
212    /// Used to substitute realistic values during KQL validation.
213    /// Keys are variable references without braces: "step.*.Column" or "step.first.Column"
214    #[serde(default)]
215    pub examples: HashMap<String, ExampleValue>,
216}
217
218/// Example value for validation substitution
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(untagged)]
221pub enum ExampleValue {
222    /// Single example value
223    Single(String),
224    /// Array of example values (for .* references)
225    Array(Vec<String>),
226}
227
228/// Acquisition step type (KQL, HTTP, File)
229///
230/// This is used in pack definitions for the `type` field of acquisition steps.
231/// For execution-layer types, see `execution::StepType`.
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
233#[serde(rename_all = "lowercase")]
234pub enum AcquisitionStepType {
235    #[default]
236    Kql,
237    Http,
238    File,
239}
240
241// Re-export as StepType for backward compatibility with Step struct
242pub use AcquisitionStepType as StepType;
243
244/// HTTP request configuration
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct HttpRequest {
247    /// HTTP method
248    pub method: HttpMethod,
249
250    /// URL (supports variable substitution)
251    pub url: String,
252
253    /// Query parameters
254    #[serde(default)]
255    pub params: HashMap<String, String>,
256
257    /// Headers
258    #[serde(default)]
259    pub headers: HashMap<String, String>,
260
261    /// Request body
262    #[serde(default)]
263    pub body: Option<serde_json::Value>,
264
265    /// Authentication method
266    #[serde(default)]
267    pub auth: Option<AuthMethod>,
268}
269
270/// HTTP method
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "UPPERCASE")]
273pub enum HttpMethod {
274    Get,
275    Post,
276    Put,
277    Delete,
278}
279
280/// Authentication method for HTTP steps
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "lowercase")]
283pub enum AuthMethod {
284    /// Use Azure CLI credential
285    Azure,
286    /// No authentication
287    None,
288}
289
290/// HTTP response field mapping
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct HttpResponse {
293    /// Field mappings (column_name -> JSONPath)
294    #[serde(default)]
295    pub fields: HashMap<String, String>,
296}
297
298/// File source configuration (for File steps)
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct FileSource {
301    /// File path (supports variable substitution)
302    pub path: String,
303
304    /// File format (auto-detected from extension if not specified)
305    #[serde(default)]
306    pub format: Option<FileFormat>,
307
308    /// CSV-specific options
309    #[serde(default)]
310    pub csv: Option<CsvOptions>,
311}
312
313/// File format
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "lowercase")]
316pub enum FileFormat {
317    Csv,
318    Json,
319    Yaml,
320}
321
322/// CSV parsing options
323#[derive(Debug, Clone, Default, Serialize, Deserialize)]
324pub struct CsvOptions {
325    /// Delimiter character (default: comma)
326    #[serde(default)]
327    pub delimiter: Option<char>,
328
329    /// Whether file has header row (default: true)
330    #[serde(default)]
331    pub has_header: Option<bool>,
332}
333
334/// Rate limiting configuration
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct RateLimitConfig {
337    /// Number of requests allowed
338    pub requests: u32,
339
340    /// Time period
341    pub per: RateLimitPeriod,
342}
343
344/// Time period for rate limiting
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum RateLimitPeriod {
348    Second,
349    Minute,
350    Hour,
351}
352
353/// Error handling behavior
354#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "lowercase")]
356pub enum OnError {
357    /// Fail the step
358    #[default]
359    Fail,
360    /// Skip and continue
361    Skip,
362    /// Record error, continue
363    Continue,
364}
365
366/// Step-level options
367#[derive(Debug, Clone, Default, Serialize, Deserialize)]
368pub struct StepOptions {
369    /// Quote style for variable substitution
370    #[serde(default)]
371    pub quote_style: Option<QuoteStyle>,
372
373    /// Deduplicate extracted values
374    #[serde(default)]
375    pub dedupe: Option<bool>,
376
377    /// Chunk size for large arrays
378    #[serde(default)]
379    pub chunk_size: Option<usize>,
380}
381
382/// Quote style for value substitution
383#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "lowercase")]
385pub enum QuoteStyle {
386    /// Single quotes: 'value'
387    #[default]
388    Single,
389    /// Double quotes: "value"
390    Double,
391    /// KQL verbatim: @'value'
392    Verbatim,
393}
394
395impl QuoteStyle {
396    /// Format a single value
397    pub fn format_value(&self, value: &str) -> String {
398        match self {
399            QuoteStyle::Single => {
400                let escaped = value.replace('\'', "''");
401                format!("'{}'", escaped)
402            }
403            QuoteStyle::Double => {
404                let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
405                format!("\"{}\"", escaped)
406            }
407            QuoteStyle::Verbatim => {
408                let escaped = value.replace('\'', "''");
409                format!("@'{}'", escaped)
410            }
411        }
412    }
413
414    /// Format an array of values
415    pub fn format_array(&self, values: &[String]) -> String {
416        values
417            .iter()
418            .map(|v| self.format_value(v))
419            .collect::<Vec<_>>()
420            .join(",")
421    }
422}
423
424/// Output folder configuration
425#[derive(Debug, Clone, Default, Serialize, Deserialize)]
426pub struct OutputConfig {
427    /// Folder template
428    #[serde(default)]
429    pub folder: Option<String>,
430}
431
432/// Secrets configuration
433#[derive(Debug, Clone, Default, Serialize, Deserialize)]
434pub struct SecretsConfig {
435    /// Secret mappings (name -> env var template)
436    #[serde(flatten)]
437    pub secrets: HashMap<String, String>,
438}
439
440// Note: ReportConfig, ScoringConfig and related types have been moved to
441// pack/reporting.rs and pack/processing.rs respectively.
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_quote_styles() {
449        assert_eq!(QuoteStyle::Single.format_value("test"), "'test'");
450        assert_eq!(QuoteStyle::Single.format_value("O'Brien"), "'O''Brien'");
451        assert_eq!(QuoteStyle::Double.format_value("test"), "\"test\"");
452        assert_eq!(QuoteStyle::Verbatim.format_value("test"), "@'test'");
453    }
454
455    #[test]
456    fn test_parse_input_value_json_arrays() {
457        // Basic array
458        assert_eq!(
459            parse_input_value(r#"["8.8.8.8", "1.1.1.1"]"#).unwrap(),
460            "8.8.8.8,1.1.1.1"
461        );
462
463        // Single item array
464        assert_eq!(
465            parse_input_value(r#"["single"]"#).unwrap(),
466            "single"
467        );
468
469        // Empty array
470        assert_eq!(
471            parse_input_value(r#"[]"#).unwrap(),
472            ""
473        );
474
475        // Array with whitespace (JSON handles this)
476        assert_eq!(
477            parse_input_value(r#"["item1" , "item2" , "item3"]"#).unwrap(),
478            "item1,item2,item3"
479        );
480
481        // Array with escaped quotes (JSON handles this)
482        assert_eq!(
483            parse_input_value(r#"["item \"with\" quotes"]"#).unwrap(),
484            r#"item "with" quotes"#
485        );
486
487        // Array with commas in values
488        assert_eq!(
489            parse_input_value(r#"["value,with,commas", "normal"]"#).unwrap(),
490            "value,with,commas,normal"
491        );
492    }
493
494    #[test]
495    fn test_parse_input_value_json_array_errors() {
496        // Non-string array items rejected
497        assert!(parse_input_value(r#"[1, 2, 3]"#).is_err());
498        assert!(parse_input_value(r#"[true, false]"#).is_err());
499        assert!(parse_input_value(r#"[null]"#).is_err());
500        assert!(parse_input_value(r#"[{"key": "value"}]"#).is_err());
501        assert!(parse_input_value(r#"[["nested"]]"#).is_err());
502    }
503
504    #[test]
505    fn test_parse_input_value_json_strings() {
506        // Double quotes (JSON standard)
507        assert_eq!(
508            parse_input_value(r#""some value""#).unwrap(),
509            "some value"
510        );
511
512        // String with escaped quotes
513        assert_eq!(
514            parse_input_value(r#""value with \"quotes\"""#).unwrap(),
515            r#"value with "quotes""#
516        );
517
518        // String with commas (kept literal)
519        assert_eq!(
520            parse_input_value(r#""a,b,c""#).unwrap(),
521            "a,b,c"
522        );
523
524        // String with escaped backslash
525        assert_eq!(
526            parse_input_value(r#""path\\to\\file""#).unwrap(),
527            r#"path\to\file"#
528        );
529
530        // String with unicode escapes
531        assert_eq!(
532            parse_input_value(r#""hello\u0020world""#).unwrap(),
533            "hello world"
534        );
535
536        // Empty string
537        assert_eq!(parse_input_value(r#""""#).unwrap(), "");
538    }
539
540    #[test]
541    fn test_parse_input_value_invalid_json_fallback() {
542        // Invalid JSON syntax falls back to raw values
543
544        // Single quotes not valid JSON - treated as raw
545        assert_eq!(parse_input_value(r#"'single quotes'"#).unwrap(), "'single quotes'");
546
547        // These look like arrays but aren't valid JSON - treated as raw
548        assert_eq!(parse_input_value(r#"[',',']"#).unwrap(), "[',',']");
549    }
550
551    #[test]
552    fn test_parse_input_value_json_type_rejection() {
553        // Numbers rejected
554        assert!(parse_input_value("123").is_err());
555        assert!(parse_input_value("123.45").is_err());
556
557        // Booleans rejected
558        assert!(parse_input_value("true").is_err());
559        assert!(parse_input_value("false").is_err());
560
561        // Null rejected
562        assert!(parse_input_value("null").is_err());
563
564        // Objects rejected
565        assert!(parse_input_value(r#"{"key": "value"}"#).is_err());
566    }
567
568    #[test]
569    fn test_parse_input_value_raw_fallback() {
570        // Raw values that aren't valid JSON are accepted as-is
571
572        // Simple identifiers
573        assert_eq!(parse_input_value("P7D").unwrap(), "P7D");
574        assert_eq!(parse_input_value("admin").unwrap(), "admin");
575
576        // Comma-separated (for array type inputs)
577        assert_eq!(
578            parse_input_value("8.8.8.8,1.1.1.1").unwrap(),
579            "8.8.8.8,1.1.1.1"
580        );
581
582        // URL-like strings
583        assert_eq!(
584            parse_input_value("https://example.com").unwrap(),
585            "https://example.com"
586        );
587
588        // Values with special chars (not valid JSON)
589        assert_eq!(parse_input_value("user@domain.com").unwrap(), "user@domain.com");
590    }
591
592    #[test]
593    fn test_parse_input_value_edge_cases() {
594        // Empty input
595        assert!(parse_input_value("").is_err());
596
597        // Whitespace only
598        assert!(parse_input_value("   ").is_err());
599
600        // Raw values that happen to be valid JSON of wrong type get rejected
601        assert!(parse_input_value("7").is_err());  // Valid JSON number
602        assert!(parse_input_value("true").is_err());  // Valid JSON boolean
603    }
604}