Skip to main content

vyre_libs/security/
relation_analyzer.rs

1//! Generated-relation analyzer contracts for security fact queries.
2//!
3//! This module keeps relation-query evidence at the security boundary while
4//! reusing the canonical [`super::facts`] fact and finding proof schema. It is
5//! intentionally host-side metadata/oracle code: GPU execution still belongs to
6//! the existing graph/dataflow primitives and external flow boundary.
7
8use std::error::Error;
9use std::fmt::{Display, Formatter};
10
11use crate::PrecisionContract;
12
13use super::facts::{
14    finding_from_sanitized_source_to_sink_query, AnalysisFact, AnalysisFactError,
15    AnalysisFactTable, FactId, FactKind, FindingProofBundle, SourceToSinkFindingRequest,
16};
17
18/// Schema version for generated security relation analyzer evidence.
19pub const SECURITY_RELATION_ANALYZER_SCHEMA_VERSION: u32 = 1;
20
21/// One selected generated relation-query family.
22#[derive(Debug, Clone, Copy, Eq, PartialEq)]
23pub enum SecurityRelationQueryFamily {
24    /// Unsanitized source-to-sink reachability.
25    UnsanitizedSourceToSink,
26}
27
28impl SecurityRelationQueryFamily {
29    /// Stable query-family id for evidence rows.
30    #[must_use]
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            Self::UnsanitizedSourceToSink => "unsanitized-source-to-sink",
34        }
35    }
36}
37
38/// Static analyzer identity and finding metadata supplied by the caller.
39#[derive(Debug, Clone, Copy)]
40pub struct GeneratedSecurityRelationAnalyzerSpec<'a> {
41    /// Generated analyzer id.
42    pub analyzer_id: &'a str,
43    /// Relation query family.
44    pub family: SecurityRelationQueryFamily,
45    /// User-facing rule id used to construct finding ids.
46    pub rule_id: &'a str,
47    /// Backend or oracle id that produced the relation result.
48    pub backend_id: &'a str,
49    /// Evidence digest or replay id for the generated analyzer run.
50    pub evidence_digest: &'a str,
51    /// Consumer precision contract for finding proof bundles.
52    pub precision_contract: PrecisionContract,
53    /// Baseline id, such as `external-ifds` or `vyre-security-current`.
54    pub baseline_id: &'a str,
55}
56
57/// Benchmark/runtime accounting supplied by the caller.
58#[derive(Debug, Clone, Copy, Eq, PartialEq)]
59pub struct GeneratedSecurityRelationAnalyzerRunStats {
60    /// Generated analyzer compile time.
61    pub compile_time_ns: u64,
62    /// Generated analyzer run time.
63    pub run_time_ns: u64,
64    /// Resident or host memory bytes consumed by relation execution.
65    pub memory_bytes: u64,
66    /// external comparator tuple count.
67    pub external_tuple_count: u32,
68    /// Current Vyre comparator tuple count.
69    pub vyre_tuple_count: u32,
70    /// external comparator finding count.
71    pub external_finding_count: u32,
72    /// Current Vyre comparator finding count.
73    pub vyre_finding_count: u32,
74}
75
76/// Evidence row emitted by a generated relation analyzer.
77#[derive(Debug, Clone, Eq, PartialEq)]
78pub struct GeneratedSecurityRelationAnalyzerEvidence {
79    /// Evidence schema version.
80    pub schema_version: u32,
81    /// Generated analyzer id.
82    pub analyzer_id: String,
83    /// Relation query family id.
84    pub query_family: &'static str,
85    /// Baseline id used for differential comparison.
86    pub baseline_id: String,
87    /// Input fact rows.
88    pub input_fact_count: u32,
89    /// Source relation tuples.
90    pub source_tuple_count: u32,
91    /// Sink relation tuples.
92    pub sink_tuple_count: u32,
93    /// Dataflow/control/call/edge relation tuples.
94    pub path_tuple_count: u32,
95    /// Sanitizer relation tuples.
96    pub sanitizer_tuple_count: u32,
97    /// Total relation tuples consumed by the generated analyzer.
98    pub generated_tuple_count: u32,
99    /// Findings emitted by the generated analyzer.
100    pub generated_finding_count: u32,
101    /// external comparator tuple count.
102    pub external_tuple_count: u32,
103    /// Current Vyre comparator tuple count.
104    pub vyre_tuple_count: u32,
105    /// external comparator finding count.
106    pub external_finding_count: u32,
107    /// Current Vyre comparator finding count.
108    pub vyre_finding_count: u32,
109    /// Generated analyzer compile time.
110    pub compile_time_ns: u64,
111    /// Generated analyzer run time.
112    pub run_time_ns: u64,
113    /// Memory bytes consumed by relation execution.
114    pub memory_bytes: u64,
115}
116
117/// Generated analyzer output plus evidence.
118#[derive(Debug, Clone, Eq, PartialEq)]
119pub struct GeneratedSecurityRelationAnalyzerReport {
120    /// Normalized evidence row.
121    pub evidence: GeneratedSecurityRelationAnalyzerEvidence,
122    /// Fact-backed findings emitted by the analyzer.
123    pub findings: Vec<FindingProofBundle>,
124}
125
126/// Relation-analyzer validation failure.
127#[derive(Debug, Clone, Eq, PartialEq)]
128pub enum SecurityRelationAnalyzerError {
129    /// Underlying fact or finding proof validation failed.
130    Fact(AnalysisFactError),
131    /// Required caller-supplied identity field was blank.
132    BlankIdentity {
133        /// Field name.
134        field: &'static str,
135    },
136    /// Runtime/benchmark accounting was missing.
137    MissingAccounting {
138        /// Field name.
139        field: &'static str,
140    },
141    /// Comparator baseline disagreed with generated relation output.
142    BaselineMismatch {
143        /// Baseline id.
144        baseline_id: String,
145        /// Compared field.
146        field: &'static str,
147        /// Generated value.
148        generated: u32,
149        /// Comparator value.
150        comparator: u32,
151    },
152}
153
154impl Display for SecurityRelationAnalyzerError {
155    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
156        match self {
157            Self::Fact(error) => Display::fmt(error, f),
158            Self::BlankIdentity { field } => write!(
159                f,
160                "generated security relation analyzer field `{field}` is blank. Fix: record stable analyzer, rule, backend, evidence, and baseline ids."
161            ),
162            Self::MissingAccounting { field } => write!(
163                f,
164                "generated security relation analyzer accounting field `{field}` is zero. Fix: record compile time, run time, and memory bytes in benchmark evidence."
165            ),
166            Self::BaselineMismatch {
167                baseline_id,
168                field,
169                generated,
170                comparator,
171            } => write!(
172                f,
173                "generated security relation analyzer baseline `{baseline_id}` disagrees for `{field}`: generated={generated}, comparator={comparator}. Fix: repair relation lowering or comparator fixture before accepting evidence."
174            ),
175        }
176    }
177}
178
179impl Error for SecurityRelationAnalyzerError {}
180
181impl From<AnalysisFactError> for SecurityRelationAnalyzerError {
182    fn from(error: AnalysisFactError) -> Self {
183        Self::Fact(error)
184    }
185}
186
187/// Run the selected generated relation analyzer against canonical security facts.
188///
189/// # Errors
190///
191/// Returns [`SecurityRelationAnalyzerError`] when the fact table is malformed,
192/// relation proof bundles are invalid, required metadata is blank, runtime
193/// accounting is missing, or comparator counts disagree.
194pub fn run_generated_security_relation_analyzer(
195    table: &AnalysisFactTable,
196    spec: GeneratedSecurityRelationAnalyzerSpec<'_>,
197    stats: GeneratedSecurityRelationAnalyzerRunStats,
198) -> Result<GeneratedSecurityRelationAnalyzerReport, SecurityRelationAnalyzerError> {
199    validate_spec(spec)?;
200    validate_stats(stats)?;
201    table.validate()?;
202
203    let relation_sets = SecurityRelationSets::from_table(table)?;
204    let findings = match spec.family {
205        SecurityRelationQueryFamily::UnsanitizedSourceToSink => {
206            run_unsanitized_source_to_sink(table, spec, &relation_sets)?
207        }
208    };
209    let generated_tuple_count = relation_sets.generated_tuple_count()?;
210    let generated_finding_count = u32::try_from(findings.len()).map_err(|_| {
211        SecurityRelationAnalyzerError::BaselineMismatch {
212            baseline_id: spec.baseline_id.to_string(),
213            field: "generated_finding_count",
214            generated: u32::MAX,
215            comparator: 0,
216        }
217    })?;
218    compare_baseline(
219        spec.baseline_id,
220        "tuple_count",
221        generated_tuple_count,
222        stats.external_tuple_count,
223    )?;
224    compare_baseline(
225        spec.baseline_id,
226        "tuple_count",
227        generated_tuple_count,
228        stats.vyre_tuple_count,
229    )?;
230    compare_baseline(
231        spec.baseline_id,
232        "finding_count",
233        generated_finding_count,
234        stats.external_finding_count,
235    )?;
236    compare_baseline(
237        spec.baseline_id,
238        "finding_count",
239        generated_finding_count,
240        stats.vyre_finding_count,
241    )?;
242
243    Ok(GeneratedSecurityRelationAnalyzerReport {
244        evidence: GeneratedSecurityRelationAnalyzerEvidence {
245            schema_version: SECURITY_RELATION_ANALYZER_SCHEMA_VERSION,
246            analyzer_id: spec.analyzer_id.to_string(),
247            query_family: spec.family.as_str(),
248            baseline_id: spec.baseline_id.to_string(),
249            input_fact_count: u32::try_from(table.facts.len()).unwrap_or(u32::MAX),
250            source_tuple_count: u32::try_from(relation_sets.sources.len()).unwrap_or(u32::MAX),
251            sink_tuple_count: u32::try_from(relation_sets.sinks.len()).unwrap_or(u32::MAX),
252            path_tuple_count: u32::try_from(relation_sets.paths.len()).unwrap_or(u32::MAX),
253            sanitizer_tuple_count: u32::try_from(relation_sets.sanitizers.len())
254                .unwrap_or(u32::MAX),
255            generated_tuple_count,
256            generated_finding_count,
257            external_tuple_count: stats.external_tuple_count,
258            vyre_tuple_count: stats.vyre_tuple_count,
259            external_finding_count: stats.external_finding_count,
260            vyre_finding_count: stats.vyre_finding_count,
261            compile_time_ns: stats.compile_time_ns,
262            run_time_ns: stats.run_time_ns,
263            memory_bytes: stats.memory_bytes,
264        },
265        findings,
266    })
267}
268
269fn validate_spec(
270    spec: GeneratedSecurityRelationAnalyzerSpec<'_>,
271) -> Result<(), SecurityRelationAnalyzerError> {
272    for (field, value) in [
273        ("analyzer_id", spec.analyzer_id),
274        ("rule_id", spec.rule_id),
275        ("backend_id", spec.backend_id),
276        ("evidence_digest", spec.evidence_digest),
277        ("baseline_id", spec.baseline_id),
278    ] {
279        if value.trim().is_empty() {
280            return Err(SecurityRelationAnalyzerError::BlankIdentity { field });
281        }
282    }
283    Ok(())
284}
285
286fn validate_stats(
287    stats: GeneratedSecurityRelationAnalyzerRunStats,
288) -> Result<(), SecurityRelationAnalyzerError> {
289    for (field, value) in [
290        ("compile_time_ns", stats.compile_time_ns),
291        ("run_time_ns", stats.run_time_ns),
292        ("memory_bytes", stats.memory_bytes),
293    ] {
294        if value == 0 {
295            return Err(SecurityRelationAnalyzerError::MissingAccounting { field });
296        }
297    }
298    Ok(())
299}
300
301fn compare_baseline(
302    baseline_id: &str,
303    field: &'static str,
304    generated: u32,
305    comparator: u32,
306) -> Result<(), SecurityRelationAnalyzerError> {
307    if generated != comparator {
308        return Err(SecurityRelationAnalyzerError::BaselineMismatch {
309            baseline_id: baseline_id.to_string(),
310            field,
311            generated,
312            comparator,
313        });
314    }
315    Ok(())
316}
317
318struct SecurityRelationSets<'a> {
319    sources: Vec<&'a AnalysisFact>,
320    sinks: Vec<&'a AnalysisFact>,
321    paths: Vec<&'a AnalysisFact>,
322    sanitizers: Vec<&'a AnalysisFact>,
323}
324
325impl<'a> SecurityRelationSets<'a> {
326    fn from_table(table: &'a AnalysisFactTable) -> Result<Self, AnalysisFactError> {
327        table.validate()?;
328        let mut sources = Vec::new();
329        let mut sinks = Vec::new();
330        let mut paths = Vec::new();
331        let mut sanitizers = Vec::new();
332        for fact in &table.facts {
333            match fact.kind {
334                FactKind::Source => sources.push(fact),
335                FactKind::Sink => sinks.push(fact),
336                FactKind::Dataflow | FactKind::Edge | FactKind::Call | FactKind::Control => {
337                    paths.push(fact)
338                }
339                FactKind::Sanitizer => sanitizers.push(fact),
340                _ => {}
341            }
342        }
343        Ok(Self {
344            sources,
345            sinks,
346            paths,
347            sanitizers,
348        })
349    }
350
351    fn generated_tuple_count(&self) -> Result<u32, SecurityRelationAnalyzerError> {
352        let total = self
353            .sources
354            .len()
355            .checked_add(self.sinks.len())
356            .and_then(|value| value.checked_add(self.paths.len()))
357            .and_then(|value| value.checked_add(self.sanitizers.len()))
358            .ok_or(SecurityRelationAnalyzerError::BaselineMismatch {
359                baseline_id: "<generated>".to_string(),
360                field: "tuple_count",
361                generated: u32::MAX,
362                comparator: 0,
363            })?;
364        u32::try_from(total).map_err(|_| SecurityRelationAnalyzerError::BaselineMismatch {
365            baseline_id: "<generated>".to_string(),
366            field: "tuple_count",
367            generated: u32::MAX,
368            comparator: 0,
369        })
370    }
371}
372
373fn run_unsanitized_source_to_sink(
374    table: &AnalysisFactTable,
375    spec: GeneratedSecurityRelationAnalyzerSpec<'_>,
376    relation_sets: &SecurityRelationSets<'_>,
377) -> Result<Vec<FindingProofBundle>, SecurityRelationAnalyzerError> {
378    let mut findings = Vec::new();
379    for source in &relation_sets.sources {
380        for sink in &relation_sets.sinks {
381            let path_fact_ids = relation_sets
382                .paths
383                .iter()
384                .filter(|path| relates(path, source.subject, sink.subject))
385                .map(|path| path.id)
386                .collect::<Vec<_>>();
387            if path_fact_ids.is_empty() {
388                continue;
389            }
390            let sanitizer_fact_ids = relation_sets
391                .sanitizers
392                .iter()
393                .filter(|sanitizer| relates(sanitizer, source.subject, sink.subject))
394                .map(|sanitizer| sanitizer.id)
395                .collect::<Vec<_>>();
396            if !sanitizer_fact_ids.is_empty() {
397                continue;
398            }
399            let request = SourceToSinkFindingRequest {
400                finding_id: format!("{}:{}:{}", spec.rule_id, source.id.0, sink.id.0),
401                query_id: spec.analyzer_id.to_string(),
402                backend_id: spec.backend_id.to_string(),
403                evidence_digest: spec.evidence_digest.to_string(),
404                precision_contract: spec.precision_contract,
405                source_fact_id: source.id,
406                sink_fact_id: sink.id,
407                path_fact_ids,
408                sanitizer_fact_ids,
409                query_hit: 1,
410                confidence_bps: source.confidence_bps.min(sink.confidence_bps),
411                reason: "generated relation analyzer found an unsanitized source-to-sink path"
412                    .to_string(),
413            };
414            if let Some(bundle) = finding_from_sanitized_source_to_sink_query(table, request)? {
415                findings.push(bundle);
416            }
417        }
418    }
419    findings.sort_by(|left, right| left.finding_id.cmp(&right.finding_id));
420    Ok(findings)
421}
422
423fn relates(fact: &AnalysisFact, source_subject: u64, sink_subject: u64) -> bool {
424    fact.subject == source_subject && fact.object == Some(sink_subject)
425}
426
427/// Collect unique fact ids from relation analyzer findings.
428#[must_use]
429pub fn generated_relation_finding_fact_ids(findings: &[FindingProofBundle]) -> Vec<FactId> {
430    let mut ids = Vec::new();
431    for finding in findings {
432        for fact_id in &finding.fact_ids {
433            if !ids.contains(fact_id) {
434                ids.push(*fact_id);
435            }
436        }
437    }
438    ids
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::dataflow::PrecisionContract;
445    use crate::security::{AnalysisSourceSpan, FactId};
446
447    fn span(byte: u32) -> AnalysisSourceSpan {
448        AnalysisSourceSpan::byte_range(1, byte, byte + 1)
449    }
450
451    fn fact(id: u64, kind: FactKind, subject: u64, object: Option<u64>) -> AnalysisFact {
452        let mut fact = AnalysisFact::exact(FactId(id), kind, span(id as u32), subject);
453        fact.object = object;
454        fact
455    }
456
457    fn spec() -> GeneratedSecurityRelationAnalyzerSpec<'static> {
458        GeneratedSecurityRelationAnalyzerSpec {
459            analyzer_id: "generated.security.unsanitized-source-sink",
460            family: SecurityRelationQueryFamily::UnsanitizedSourceToSink,
461            rule_id: "SEC-GEN-001",
462            backend_id: "generated-relation-oracle",
463            evidence_digest: "relation-evidence-digest",
464            precision_contract: PrecisionContract::ZeroFalsePositive,
465            baseline_id: "external-ifds+vyre-current",
466        }
467    }
468
469    fn stats(tuple_count: u32, finding_count: u32) -> GeneratedSecurityRelationAnalyzerRunStats {
470        GeneratedSecurityRelationAnalyzerRunStats {
471            compile_time_ns: 11,
472            run_time_ns: 17,
473            memory_bytes: 128,
474            external_tuple_count: tuple_count,
475            vyre_tuple_count: tuple_count,
476            external_finding_count: finding_count,
477            vyre_finding_count: finding_count,
478        }
479    }
480
481    #[test]
482    fn generated_relation_analyzer_emits_fact_backed_finding_and_evidence() {
483        let table = AnalysisFactTable::new(vec![
484            fact(1, FactKind::Source, 10, None),
485            fact(2, FactKind::Sink, 20, None),
486            fact(3, FactKind::Dataflow, 10, Some(20)),
487        ]);
488
489        let report = run_generated_security_relation_analyzer(&table, spec(), stats(3, 1)).unwrap();
490
491        assert_eq!(
492            report.evidence.schema_version,
493            SECURITY_RELATION_ANALYZER_SCHEMA_VERSION
494        );
495        assert_eq!(report.evidence.source_tuple_count, 1);
496        assert_eq!(report.evidence.sink_tuple_count, 1);
497        assert_eq!(report.evidence.path_tuple_count, 1);
498        assert_eq!(report.evidence.generated_tuple_count, 3);
499        assert_eq!(report.evidence.generated_finding_count, 1);
500        assert_eq!(report.evidence.compile_time_ns, 11);
501        assert_eq!(report.evidence.run_time_ns, 17);
502        assert_eq!(report.evidence.memory_bytes, 128);
503        assert_eq!(report.findings[0].finding_id, "SEC-GEN-001:1:2");
504        assert_eq!(
505            generated_relation_finding_fact_ids(&report.findings),
506            vec![FactId(1), FactId(3), FactId(2)]
507        );
508    }
509
510    #[test]
511    fn generated_relation_analyzer_suppresses_sanitized_path() {
512        let table = AnalysisFactTable::new(vec![
513            fact(1, FactKind::Source, 10, None),
514            fact(2, FactKind::Sink, 20, None),
515            fact(3, FactKind::Dataflow, 10, Some(20)),
516            fact(4, FactKind::Sanitizer, 10, Some(20)),
517        ]);
518
519        let report = run_generated_security_relation_analyzer(&table, spec(), stats(4, 0)).unwrap();
520
521        assert!(report.findings.is_empty());
522        assert_eq!(report.evidence.sanitizer_tuple_count, 1);
523        assert_eq!(report.evidence.generated_finding_count, 0);
524    }
525
526    #[test]
527    fn generated_relation_analyzer_rejects_baseline_count_mismatch() {
528        let table = AnalysisFactTable::new(vec![
529            fact(1, FactKind::Source, 10, None),
530            fact(2, FactKind::Sink, 20, None),
531            fact(3, FactKind::Dataflow, 10, Some(20)),
532        ]);
533
534        let error = run_generated_security_relation_analyzer(&table, spec(), stats(2, 1))
535            .expect_err("baseline tuple mismatch must reject");
536
537        assert!(matches!(
538            error,
539            SecurityRelationAnalyzerError::BaselineMismatch {
540                field: "tuple_count",
541                generated: 3,
542                comparator: 2,
543                ..
544            }
545        ));
546    }
547
548    #[test]
549    fn generated_relation_analyzer_requires_compile_run_and_memory_accounting() {
550        let table = AnalysisFactTable::new(vec![
551            fact(1, FactKind::Source, 10, None),
552            fact(2, FactKind::Sink, 20, None),
553            fact(3, FactKind::Dataflow, 10, Some(20)),
554        ]);
555        let mut missing = stats(3, 1);
556        missing.compile_time_ns = 0;
557
558        let error = run_generated_security_relation_analyzer(&table, spec(), missing)
559            .expect_err("missing compile time must reject");
560
561        assert!(matches!(
562            error,
563            SecurityRelationAnalyzerError::MissingAccounting {
564                field: "compile_time_ns"
565            }
566        ));
567    }
568}