1use regex::Regex;
2use std::{fmt, panic::AssertUnwindSafe, sync::LazyLock};
3use termivar_core::{EvidenceKind, EvidenceValue, KnowledgePredicate};
4
5use super::{
6 limits::{
7 invalid_config, validate_identifier, HARD_MAX_PLUGIN_OBSERVATION_BYTES,
8 HARD_MAX_PLUGIN_TEXT_LIST_ITEMS, MAX_PLUGIN_ID_BYTES, MAX_PLUGIN_REDACTION_LITERAL_BYTES,
9 MAX_PLUGIN_REDACTION_LITERAL_COUNT, MAX_PLUGIN_TEXT_BYTES,
10 },
11 PluginError, PLUGIN_API_VERSION,
12};
13
14pub trait PluginRedactionPolicy: Send + Sync {
16 fn redact(&self, value: &str) -> String;
18}
19
20#[derive(Clone, Default)]
22pub struct SecretRedactionPolicy {
23 literals: Vec<String>,
24}
25
26impl SecretRedactionPolicy {
27 pub fn new(literals: impl IntoIterator<Item = String>) -> Result<Self, PluginError> {
29 let mut retained = Vec::new();
30 for literal in literals {
31 if literal.is_empty() || literal.len() > MAX_PLUGIN_REDACTION_LITERAL_BYTES {
32 return Err(invalid_config("redaction literal is empty or too long"));
33 }
34 if retained.len() >= MAX_PLUGIN_REDACTION_LITERAL_COUNT {
35 return Err(invalid_config("too many redaction literals"));
36 }
37 if !retained.contains(&literal) {
38 retained.push(literal);
39 }
40 }
41 retained.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
42 Ok(Self { literals: retained })
43 }
44}
45
46impl fmt::Debug for SecretRedactionPolicy {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 formatter
49 .debug_struct("SecretRedactionPolicy")
50 .field("literal_count", &self.literals.len())
51 .finish()
52 }
53}
54
55impl PluginRedactionPolicy for SecretRedactionPolicy {
56 fn redact(&self, value: &str) -> String {
57 static ASSIGNMENTS: LazyLock<Option<Regex>> = LazyLock::new(|| {
58 Regex::new(
59 r"(?im)(authorization|proxy-authorization|cookie|set-cookie|api[-_]?key|token|password|secret)\s*[:=]\s*[^\r\n]*",
60 )
61 .ok()
62 });
63
64 let redacted = match ASSIGNMENTS.as_ref() {
65 Some(pattern) => pattern.replace_all(value, "$1=[REDACTED]").into_owned(),
66 None => "[REDACTED]".to_owned(),
67 };
68 redact_literals_once(&redacted, &self.literals)
69 }
70}
71
72fn redact_literals_once(value: &str, literals: &[String]) -> String {
73 const REDACTED: &str = "[REDACTED]";
74 if literals.is_empty() {
75 return value.to_owned();
76 }
77
78 let mut masked = vec![false; value.len()];
82 for pattern in std::iter::once(REDACTED).chain(literals.iter().map(String::as_str)) {
83 for (start, matched) in value.match_indices(pattern) {
84 masked[start..start + matched.len()].fill(true);
85 }
86 }
87 if !masked.iter().any(|is_masked| *is_masked) {
88 return value.to_owned();
89 }
90
91 let mut output = String::with_capacity(value.len());
92 let mut cursor = 0;
93 while cursor < masked.len() {
94 let start = cursor;
95 let is_masked = masked[cursor];
96 while cursor < masked.len() && masked[cursor] == is_masked {
97 cursor += 1;
98 }
99 if is_masked {
100 output.push_str(REDACTED);
101 } else {
102 output.push_str(&value[start..cursor]);
103 }
104 }
105 output
106}
107
108pub struct PluginObservation {
110 pub(super) kind: EvidenceKind,
111 pub(super) predicate: KnowledgePredicate,
112 pub(super) value: EvidenceValue,
113 pub(super) method: String,
114}
115
116impl PluginObservation {
117 pub fn new(
119 kind: EvidenceKind,
120 predicate: KnowledgePredicate,
121 value: EvidenceValue,
122 method: impl Into<String>,
123 ) -> Result<Self, PluginError> {
124 let method = method.into();
125 validate_identifier(&method, "plugin observation method", MAX_PLUGIN_ID_BYTES)?;
126 validate_identifier(
127 predicate.namespace(),
128 "plugin observation predicate namespace",
129 MAX_PLUGIN_ID_BYTES,
130 )?;
131 validate_identifier(
132 predicate.name(),
133 "plugin observation predicate name",
134 MAX_PLUGIN_ID_BYTES,
135 )?;
136 if let EvidenceKind::Custom(name) = &kind {
137 validate_identifier(name, "plugin observation kind", MAX_PLUGIN_ID_BYTES)?;
138 }
139 validate_observation_value(&value)?;
140 Ok(Self {
141 kind,
142 predicate,
143 value,
144 method,
145 })
146 }
147}
148
149impl fmt::Debug for PluginObservation {
150 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151 formatter
152 .debug_struct("PluginObservation")
153 .field("kind", &self.kind)
154 .field("predicate", &self.predicate)
155 .field("value", &"[redacted]")
156 .field("method", &self.method)
157 .finish()
158 }
159}
160
161pub(super) fn validate_observation_value(value: &EvidenceValue) -> Result<(), PluginError> {
162 match value {
163 EvidenceValue::Text(text) => {
164 if text.len() as u64 > HARD_MAX_PLUGIN_OBSERVATION_BYTES {
165 return Err(PluginError::ObservationBytesBudgetExceeded);
166 }
167 },
168 EvidenceValue::TextList(items) => {
169 if items.len() > HARD_MAX_PLUGIN_TEXT_LIST_ITEMS
170 || evidence_value_bytes(value) > HARD_MAX_PLUGIN_OBSERVATION_BYTES
171 {
172 return Err(PluginError::ObservationBytesBudgetExceeded);
173 }
174 },
175 EvidenceValue::Boolean(_) | EvidenceValue::Signed(_) | EvidenceValue::Unsigned(_) => {},
176 _ => return Err(PluginError::ObservationBytesBudgetExceeded),
177 }
178 Ok(())
179}
180
181pub(super) fn evidence_value_bytes(value: &EvidenceValue) -> u64 {
182 match value {
183 EvidenceValue::Boolean(_) => 1,
184 EvidenceValue::Signed(_) | EvidenceValue::Unsigned(_) => 8,
185 EvidenceValue::Text(text) => {
186 8_u64.saturating_add(u64::try_from(text.len()).unwrap_or(u64::MAX))
187 },
188 EvidenceValue::TextList(items) => items.iter().fold(8_u64, |total, item| {
189 total
190 .saturating_add(8)
191 .saturating_add(u64::try_from(item.len()).unwrap_or(u64::MAX))
192 }),
193 _ => u64::MAX,
194 }
195}
196
197pub(super) fn redact_value(
198 policy: &dyn PluginRedactionPolicy,
199 value: EvidenceValue,
200) -> EvidenceValue {
201 match value {
202 EvidenceValue::Text(text) => EvidenceValue::Text(policy.redact(&text)),
203 EvidenceValue::TextList(items) => {
204 EvidenceValue::TextList(items.into_iter().map(|item| policy.redact(&item)).collect())
205 },
206 other => other,
207 }
208}
209
210pub(super) fn sanitize_error(
211 policy: &dyn PluginRedactionPolicy,
212 error: PluginError,
213) -> PluginError {
214 match error {
215 PluginError::InvalidConfig(_) => PluginError::InvalidConfig(redact_host_detail(
216 policy,
217 "plugin rejected its configuration",
218 )),
219 PluginError::ExecutionFailed(_) => {
220 PluginError::ExecutionFailed(redact_host_detail(policy, "plugin execution failed"))
221 },
222 PluginError::BrokerFailure(_) => PluginError::BrokerFailure(redact_host_detail(
223 policy,
224 "host plugin request broker failed",
225 )),
226 PluginError::IncompatibleApiVersion { .. } => PluginError::IncompatibleApiVersion {
227 expected: PLUGIN_API_VERSION.to_owned(),
228 actual: "[invalid]".to_owned(),
229 },
230 other => other,
231 }
232}
233
234pub(super) fn sanitize_error_safely(
235 policy: &dyn PluginRedactionPolicy,
236 error: PluginError,
237) -> Result<PluginError, PluginError> {
238 std::panic::catch_unwind(AssertUnwindSafe(|| sanitize_error(policy, error)))
239 .map_err(|_| PluginError::HostCallbackPanicked)
240}
241
242fn redact_host_detail(policy: &dyn PluginRedactionPolicy, value: &'static str) -> String {
243 bounded_detail(&policy.redact(value))
244}
245
246fn bounded_detail(value: &str) -> String {
247 let mut end = value.len().min(MAX_PLUGIN_TEXT_BYTES);
248 while !value.is_char_boundary(end) {
249 end = end.saturating_sub(1);
250 }
251 value[..end].to_owned()
252}