Skip to main content

keyhog_scanner/structured/parsers/
yaml.rs

1use super::{line::find_line_number, ExtractedPair};
2
3/// Parse a Kubernetes Secret YAML and decode base64 values under `data:`.
4///
5/// Line-number lookup anchors on the key (`<key>:`) rather than the
6/// encoded value: two different keys in the same Secret CAN encode the
7/// same byte body, and matching on the encoded blob would route both
8/// findings to the first occurrence.
9pub fn parse_k8s_secret(text: &str) -> Vec<ExtractedPair> {
10    let mut pairs = Vec::new();
11    let value: serde_yaml::Value = match serde_yaml::from_str(text) {
12        Ok(v) => v,
13        Err(error) => {
14            tracing::debug!(target: "keyhog::structured", %error, "k8s secret YAML parse failed");
15            return pairs;
16        }
17    };
18
19    if let Some(serde_yaml::Value::Mapping(map)) = value.get("data") {
20        for (k, v) in map {
21            let key = k.as_str().unwrap_or_default();
22            let encoded = v.as_str().unwrap_or_default();
23            if key.is_empty() || encoded.is_empty() {
24                continue;
25            }
26            let decoded = match keyhog_core::encoding::decode_standard_base64(encoded) {
27                Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
28                Err(_) => continue,
29            };
30            let line = find_line_number(text, &format!("{}:", key))
31                .or_else(|| find_line_number(text, encoded))
32                .unwrap_or(1);
33            pairs.push(ExtractedPair {
34                context: key.to_string(),
35                value: decoded,
36                line,
37            });
38        }
39    }
40
41    if let Some(serde_yaml::Value::Mapping(map)) = value.get("stringData") {
42        for (k, v) in map {
43            let key = k.as_str().unwrap_or_default();
44            let secret_value = v.as_str().unwrap_or_default().to_string();
45            if key.is_empty() {
46                continue;
47            }
48            let line = find_line_number(text, key).unwrap_or(1);
49            pairs.push(ExtractedPair {
50                context: key.to_string(),
51                value: secret_value,
52                line,
53            });
54        }
55    }
56
57    pairs
58}
59
60/// Parse docker-compose.yml environment blocks.
61pub fn parse_docker_compose(text: &str) -> Vec<ExtractedPair> {
62    let mut pairs = Vec::new();
63    let value: serde_yaml::Value = match serde_yaml::from_str(text) {
64        Ok(v) => v,
65        Err(error) => {
66            tracing::debug!(target: "keyhog::structured", %error, "docker-compose YAML parse failed");
67            return pairs;
68        }
69    };
70    find_environment_pairs(&value, text, &mut pairs, 0);
71    pairs
72}
73
74/// Cap recursion depth on adversarial YAML. Real docker-compose schemas nest
75/// about six levels deep; 256 stays permissive while preventing stack overflow.
76const MAX_COMPOSE_DEPTH: usize = 256;
77
78fn find_environment_pairs(
79    value: &serde_yaml::Value,
80    text: &str,
81    pairs: &mut Vec<ExtractedPair>,
82    depth: usize,
83) {
84    if depth >= MAX_COMPOSE_DEPTH {
85        return;
86    }
87    match value {
88        serde_yaml::Value::Mapping(map) => {
89            for (k, v) in map {
90                if k.as_str() == Some("environment") {
91                    extract_environment_block(v, text, pairs);
92                } else {
93                    find_environment_pairs(v, text, pairs, depth + 1);
94                }
95            }
96        }
97        serde_yaml::Value::Sequence(seq) => {
98            for v in seq {
99                find_environment_pairs(v, text, pairs, depth + 1);
100            }
101        }
102        _ => {}
103    }
104}
105
106fn extract_environment_block(
107    value: &serde_yaml::Value,
108    text: &str,
109    pairs: &mut Vec<ExtractedPair>,
110) {
111    match value {
112        serde_yaml::Value::Mapping(map) => {
113            for (k, v) in map {
114                let key = k.as_str().unwrap_or_default();
115                let val = v.as_str().unwrap_or_default().to_string();
116                if key.is_empty() {
117                    continue;
118                }
119                let line = find_line_number(text, key).unwrap_or(1);
120                pairs.push(ExtractedPair {
121                    context: key.to_string(),
122                    value: val,
123                    line,
124                });
125            }
126        }
127        serde_yaml::Value::Sequence(seq) => {
128            for item in seq {
129                if let Some(s) = item.as_str() {
130                    if let Some((key, val)) = s.split_once('=') {
131                        if key.is_empty() {
132                            continue;
133                        }
134                        let line = find_line_number(text, s).unwrap_or(1);
135                        pairs.push(ExtractedPair {
136                            context: key.to_string(),
137                            value: val.to_string(),
138                            line,
139                        });
140                    }
141                }
142            }
143        }
144        _ => {}
145    }
146}