bonsai-ninja-security 0.1.0

Security rulepack loader, matcher, and source/sink/sanitizer wrapper for bonsai-ninja.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! Findings — semantic sink vulnerabilities with stable `S:` content-hash ids.
//!
//! `S:` id construction: FNV-1a-64 over normalized source identities, sink
//! identities, the representative route group, and language. Stable across
//! runs, cache state, and render mode; changes only when semantic finding
//! identity changes.

use crate::matcher::RuleMatch;
use crate::rule::{MatchOrigin, Rule, Severity};
use bonsai_hash::fnv1a_names64;
use serde::{Deserialize, Serialize};

/// Sanitizer outcome on the chain. Drives review priority and
/// rendering — sanitization means the developer attempted to filter,
/// not that the value is safe (see security-spec.mdx "Sanitized Does
/// Not Mean Safe"). Findings are surfaced regardless; status governs
/// where they appear and at what severity floor.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FindingStatus {
    /// At least one retained route has no credited sanitizer. Full severity,
    /// main findings list.
    Unsanitized,
    /// Every retained route has a sanitizer credited to the sink tag.
    /// Severity floor applied; rendered in a separate "review for bypass"
    /// section.
    Sanitized,
    /// Sanitizer fired on the chain but its tag does NOT credit the
    /// sink tag (e.g. HTML-encoded value used in URL context). Full
    /// severity preserved + a WRONG-CONTEXT warning, because the
    /// developer thought they were protected.
    WrongContext,
}

impl FindingStatus {
    /// Stable kebab-case string for serialization and rendering.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Unsanitized => "unsanitized",
            Self::Sanitized => "sanitized",
            Self::WrongContext => "wrong-context",
        }
    }

    /// Merge rank for grouping: when several findings collapse into
    /// one group, the LEAST-mitigated path wins (any Unsanitized chain
    /// drags the whole group to Unsanitized).
    fn merge_rank(self) -> u8 {
        match self {
            Self::Unsanitized => 2,
            Self::WrongContext => 1,
            Self::Sanitized => 0,
        }
    }

    /// Combine two statuses for the same group: pick the
    /// least-mitigated of the two.
    pub fn merge(self, other: Self) -> Self {
        if self.merge_rank() >= other.merge_rank() {
            self
        } else {
            other
        }
    }
}

impl Default for FindingStatus {
    fn default() -> Self {
        Self::Unsanitized
    }
}

/// One match site on either side of a finding. Mirrors the spec's
/// `match_site` block.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FindingMatch {
    /// Internal typed provenance. Stable rule ids remain presentation
    /// identities and are never parsed to recover analysis behavior.
    #[serde(skip)]
    pub origin: MatchOrigin,
    pub rule_id: String,
    pub file: String,
    pub line: u32,
    pub column: u32,
    pub text: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub enclosing_fn: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tag: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub severity: Option<Severity>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trust: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub payload_types: Vec<String>,
    /// Sink-side: the per-arg taint evidence at this sink call site.
    /// Source / sanitizer sides leave this empty. Lets the consumer
    /// (LLM or human) tell "URL is tainted" from "body is tainted"
    /// without re-parsing the call.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub tainted_args: Vec<TaintedArgInfo>,
    /// Sanitizer-side: arg indices the sanitizer rule's discriminating
    /// constraint named (`arg_matches_regex` / `arg_equals` /
    /// `keyword_arg_equals`). Lets the consumer tell which arg got
    /// wrapped — `format(safe, raw)` reports `[0]` so reviewers know
    /// arg 1 still flows raw. Empty when the sanitizer cleanses by
    /// call shape alone (no arg-pointing constraint).
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub sanitised_arg_indices: Vec<u32>,
}

/// Per-arg taint evidence rendered on the sink side of a finding. The
/// SHAPE of evidence — bonsai surfaces it, the consumer (LLM or
/// human) interprets it.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TaintedArgInfo {
    /// Positional index in the call's argument list (`usize::MAX` for
    /// receiver, in keeping with the dump command's convention).
    pub index: usize,
    /// The argument's identifier text as it appeared in source.
    pub value_text: String,
    /// Canonical place lowered from the argument AST, when the argument is
    /// addressable as one value-flow location.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub place: Option<String>,
    /// Identifier reads lowered from the argument AST. Consumers should use
    /// these semantic operands instead of re-tokenizing `value_text`.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub source_names: Vec<String>,
}

/// One taint-propagation edge preserved for report consumers. Unlike the
/// display-only function chain, this names the concrete call site and
/// every argument the taint engine propagated across that edge.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TaintPropagationStep {
    pub caller: String,
    pub callee: String,
    pub file: String,
    pub line: u32,
    pub column: u32,
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub tainted_args: Vec<TaintPropagationArg>,
}

#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TaintPropagationArg {
    pub index: usize,
    pub value_text: String,
    pub param_name: String,
}

impl FindingMatch {
    /// Build a match site from a matcher hit + the rule that fired,
    /// stamping rule-derived metadata (tag, severity, trust, payload
    /// types) onto the location data.
    pub fn from_rule_match(rule_match: &RuleMatch, rule: &Rule) -> Self {
        Self {
            origin: rule_match.origin,
            rule_id: rule_match.rule_id.clone(),
            file: rule_match.file.clone(),
            line: rule_match.line,
            column: rule_match.column,
            text: rule_match.match_text.clone(),
            enclosing_fn: rule_match.enclosing_fn.clone(),
            tag: rule.tag.clone(),
            severity: rule.severity,
            category: rule.category.clone(),
            trust: rule.trust.map(|trust_class| trust_class.as_str().to_string()),
            payload_types: rule
                .payload_types
                .iter()
                .map(|payload_type| payload_type.as_str().to_string())
                .collect(),
            tainted_args: Vec::new(),
            sanitised_arg_indices: sanitised_indices_from_rule(rule),
        }
    }

    /// Build a synthetic match site for a source that isn't declared
    /// in the rulepack — specifically the inferred entry-point
    /// parameter sources produced by
    /// `matcher::infer_entry_point_sources`. Carries the "local"
    /// trust tag (the param's taint origin is known to be user input
    /// but without framework context we can't prove "remote") and
    /// leaves rulepack-derived fields at defaults.
    #[must_use]
    pub fn from_inferred(rule_match: &RuleMatch) -> Self {
        Self {
            origin: rule_match.origin,
            rule_id: rule_match.rule_id.clone(),
            file: rule_match.file.clone(),
            line: rule_match.line,
            column: rule_match.column,
            text: rule_match.match_text.clone(),
            enclosing_fn: rule_match.enclosing_fn.clone(),
            tag: Some("entry-point".to_string()),
            severity: None,
            category: Some("inferred".to_string()),
            trust: Some("local".to_string()),
            payload_types: Vec::new(),
            tainted_args: Vec::new(),
            sanitised_arg_indices: Vec::new(),
        }
    }
}

/// Extract every arg index named by an arg-pointing constraint on a
/// rule (P2 — sanitiser per-arg attribution). When the rule has no
/// such constraint, returns an empty vec — the rule cleanses by call
/// shape alone, not by a specific arg.
fn sanitised_indices_from_rule(rule: &Rule) -> Vec<u32> {
    use crate::rule::ConstraintKind;
    let mut indices: Vec<u32> = Vec::new();
    for constraint in rule.constraints.0.iter() {
        let index = match constraint {
            ConstraintKind::ArgEquals { arg_equals } => Some(arg_equals.index),
            ConstraintKind::ArgMatchesRegex { arg_matches_regex } => Some(arg_matches_regex.index),
            ConstraintKind::ArgNotMatchesRegex {
                arg_not_matches_regex,
            } => Some(arg_not_matches_regex.index),
            ConstraintKind::FormatArgIndex { format_arg_index } => Some(*format_arg_index),
            ConstraintKind::SecondArgEquals { .. } => Some(1),
            ConstraintKind::ArgLt { arg_lt } => Some(arg_lt.index),
            ConstraintKind::ArgLe { arg_le } => Some(arg_le.index),
            ConstraintKind::ArgGt { arg_gt } => Some(arg_gt.index),
            ConstraintKind::ArgGe { arg_ge } => Some(arg_ge.index),
            // KeywordArgEquals / ArgTainted / others either don't pin
            // a positional index or aren't sanitiser-relevant.
            _ => None,
        };
        if let Some(idx) = index {
            if !indices.contains(&idx) {
                indices.push(idx);
            }
        }
    }
    indices
}

/// One finding — a concrete semantic sink reached by one or more complete
/// source-to-sink routes through the workspace.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Finding {
    pub finding_id: String,
    pub language: String,
    pub source: FindingMatch,
    pub sink: FindingMatch,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sanitizers_seen: Vec<FindingMatch>,
    /// Taint-preserving transforms encountered on this route. These rules
    /// describe identity-style operations such as decode/encode/normalise;
    /// they are evidence that taint continued, not evidence of mitigation.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub taint_transforms_seen: Vec<FindingMatch>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub group_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub representative_flow_id: Option<String>,
    /// Whether this finding's source-to-sink evidence was computed to
    /// completion for the requested semantic scope. Public findings are
    /// emitted only from complete semantic evidence; the explicit field keeps
    /// downstream consumers from having to infer that from absence of broad
    /// precision.
    pub analysis_complete: bool,
    /// Machine-readable reasons when `analysis_complete` is false. Kept even
    /// when empty so JSON consumers can treat taint-analysis and
    /// source-analysis rows uniformly.
    pub analysis_incomplete_reasons: Vec<String>,
    /// Function-level chain (entry → … → sink-enclosing-fn) for this
    /// finding's representative flow. Empty for pattern-only findings
    /// and direct cases without a multi-hop resolved lineage.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub chain_display: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub taint_path: Vec<TaintPropagationStep>,
    /// Other complete source-to-sink routes that reach this same semantic
    /// sink finding. A finding is a vulnerability at a concrete sink, not
    /// one row per path; retaining the alternate routes here avoids both
    /// duplicate results and lost provenance.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub alternate_flows: Vec<AlternateTaintFlow>,
    /// Full source body of each function along the flow, with `step`/`role`
    /// marked on the lines that carry a flow event - the same code the text
    /// view prints. Empty for findings without a resolved multi-hop chain.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hops: Vec<crate::flow_evidence::FlowFunctionBody>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tag: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub severity: Option<Severity>,
    pub precision: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub cwe: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub owasp: Vec<String>,
    /// Sanitizer outcome on the chain — see [`FindingStatus`] for the
    /// three variants and their rendering contract. Always serialized
    /// (no skip-if), so consumers can switch on it without nullable
    /// handling.
    #[serde(default)]
    pub status: FindingStatus,
    /// True when every retained route touches a common test-path convention
    /// (`test/`, `tests/`, `*_test.go`, `*Test*.java`, `Tests/` Xcode style,
    /// `__tests__/`, `*.spec.ts`, etc.). A production route reaching the same
    /// sink keeps the combined finding production-relevant.
    #[serde(default, skip_serializing_if = "is_false")]
    pub from_test: bool,
}

impl Finding {
    /// Iterate every complete source-to-sink route, representative first,
    /// while keeping all route-specific evidence paired.
    pub fn flows(&self) -> impl Iterator<Item = TaintFlowRef<'_>> {
        std::iter::once(TaintFlowRef {
            source: &self.source,
            sink_tainted_args: &self.sink.tainted_args,
            sanitizers_seen: &self.sanitizers_seen,
            taint_transforms_seen: &self.taint_transforms_seen,
            flow_id: self.representative_flow_id.as_deref(),
            chain_display: &self.chain_display,
            taint_path: &self.taint_path,
            status: self.status,
            precision: &self.precision,
        })
        .chain(self.alternate_flows.iter().map(|flow| TaintFlowRef {
            source: &flow.source,
            sink_tainted_args: &flow.sink_tainted_args,
            sanitizers_seen: &flow.sanitizers_seen,
            taint_transforms_seen: &flow.taint_transforms_seen,
            flow_id: flow.flow_id.as_deref(),
            chain_display: &flow.chain_display,
            taint_path: &flow.taint_path,
            status: flow.status,
            precision: &flow.precision,
        }))
    }

    /// Every complete route id carried by this sink finding, representative
    /// first. Consumers that inventory or link flows must use this iterator
    /// instead of reading only `representative_flow_id`.
    pub fn flow_ids(&self) -> impl Iterator<Item = &str> {
        self.flows().filter_map(|flow| flow.flow_id)
    }
}

/// A non-primary route retained on a combined sink finding.
///
/// The sink is owned by the surrounding [`Finding`]. Every alternate route
/// therefore carries only the source and route-specific evidence. This keeps
/// the public model acyclic and lets SARIF emit one result with multiple
/// `codeFlows`, as intended by the schema.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AlternateTaintFlow {
    pub source: FindingMatch,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sink_tainted_args: Vec<TaintedArgInfo>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sanitizers_seen: Vec<FindingMatch>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub taint_transforms_seen: Vec<FindingMatch>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub flow_id: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub chain_display: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub taint_path: Vec<TaintPropagationStep>,
    /// Mitigation state for this route. The surrounding finding carries the
    /// least-mitigated status across all routes.
    #[serde(default)]
    pub status: FindingStatus,
    /// Static precision for this route, kept paired with its source and path.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub precision: String,
}

/// Borrowed view of one complete route on a [`Finding`].
///
/// Consumers use this instead of separately special-casing the
/// representative route and `alternate_flows`, which keeps source, argument,
/// sanitizer, status, and precision evidence aligned.
#[derive(Copy, Clone, Debug)]
pub struct TaintFlowRef<'a> {
    pub source: &'a FindingMatch,
    pub sink_tainted_args: &'a [TaintedArgInfo],
    pub sanitizers_seen: &'a [FindingMatch],
    pub taint_transforms_seen: &'a [FindingMatch],
    pub flow_id: Option<&'a str>,
    pub chain_display: &'a [String],
    pub taint_path: &'a [TaintPropagationStep],
    pub status: FindingStatus,
    pub precision: &'a str,
}

/// Serde callback for `#[serde(skip_serializing_if = "is_false")]` —
/// elides boolean fields when they hold the default `false` value.
#[allow(clippy::trivially_copy_pass_by_ref)] // serde `skip_serializing_if` callback signature
fn is_false(value: &bool) -> bool {
    !value
}

/// True when normalized `path` matches a rulepack-declared test convention.
/// The matcher owns only path normalization; every language/ecosystem value
/// comes from `metadata.yml::test_path_patterns`.
#[must_use]
pub(crate) fn path_is_test_file(path: &str, patterns: &[String]) -> bool {
    let mut normalized = path.replace('\\', "/").to_ascii_lowercase();
    if !normalized.starts_with('/') {
        normalized.insert(0, '/');
    }
    let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
    patterns.iter().any(|pattern| {
        let pattern = pattern.replace('\\', "/").to_ascii_lowercase();
        if pattern.is_empty() {
            return false;
        }
        if pattern.starts_with('/') && pattern.ends_with('/') {
            normalized.contains(&pattern)
        } else {
            basename.ends_with(&pattern)
        }
    })
}

/// Compute the stable `S:` finding id.
///
/// Inputs are the rule ids + group id + language — the same tokens the
/// spec prescribes. Kept as a pure function so tests can verify id
/// stability in isolation.
#[must_use]
pub fn compute_finding_id(source_id: &str, sink_id: &str, group_id: &str, language: &str) -> String {
    let tokens = [
        source_id.to_string(),
        sink_id.to_string(),
        group_id.to_string(),
        language.to_string(),
    ];
    format!("S:{:016x}", fnv1a_names64(&tokens))
}