Skip to main content

termivar_scanner/plugin/
recorder.rs

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
14/// Host redaction policy applied before any plugin observation becomes evidence.
15pub trait PluginRedactionPolicy: Send + Sync {
16    /// Returns a redacted replacement for untrusted observation text.
17    fn redact(&self, value: &str) -> String;
18}
19
20/// Conservative redactor for common secret assignments plus host literals.
21#[derive(Clone, Default)]
22pub struct SecretRedactionPolicy {
23    literals: Vec<String>,
24}
25
26impl SecretRedactionPolicy {
27    /// Creates a policy with bounded, non-empty literal secrets to remove.
28    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    // A byte mask keeps transient memory proportional to the already-bounded
79    // input. Collecting every match range lets dense, overlapping literals
80    // multiply memory before the recorder can enforce its retained-byte cap.
81    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
108/// Untrusted observation draft accepted by the host recorder.
109pub 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    /// Creates a bounded observation draft without subject or claim authority.
118    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}