1use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12use vyre_foundation::hashing::update_length_delimited_field as hash_field;
13
14use crate::{
15 validate_dynamic_pipeline, DynamicPrimitiveSoundness, DynamicSoundnessViolation,
16 PrecisionContract, SharedFactHeader, SharedFactKind, Soundness,
17};
18
19#[derive(
21 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
22)]
23pub struct FactId(pub u64);
24
25impl FactId {
26 #[must_use]
28 pub const fn is_valid(self) -> bool {
29 self.0 != 0
30 }
31}
32
33#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
35pub struct AnalysisSourceSpan {
36 pub file_id: u32,
38 pub start_byte: u32,
40 pub end_byte: u32,
42 pub start_line: u32,
44 pub start_column: u32,
46 pub end_line: u32,
48 pub end_column: u32,
50}
51
52impl AnalysisSourceSpan {
53 #[must_use]
55 pub const fn byte_range(file_id: u32, start_byte: u32, end_byte: u32) -> Self {
56 Self {
57 file_id,
58 start_byte,
59 end_byte,
60 start_line: 0,
61 start_column: 0,
62 end_line: 0,
63 end_column: 0,
64 }
65 }
66
67 pub fn validate(&self, context: &str) -> Result<(), AnalysisFactError> {
72 if self.end_byte < self.start_byte {
73 return Err(AnalysisFactError::InvalidSpan {
74 context: context.to_string(),
75 start_byte: self.start_byte,
76 end_byte: self.end_byte,
77 });
78 }
79 Ok(())
80 }
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
85pub enum FactKind {
86 Node,
88 Edge,
90 Symbol,
92 Call,
94 Dataflow,
96 Control,
98 Auth,
100 Sanitizer,
102 Sink,
104 Source,
106 Type,
108 Lifetime,
110 Concurrency,
112 Provenance,
114}
115
116impl FactKind {
117 #[must_use]
119 pub const fn tag(self) -> u16 {
120 match self {
121 Self::Node => 1,
122 Self::Edge => 2,
123 Self::Symbol => 3,
124 Self::Call => 4,
125 Self::Dataflow => 5,
126 Self::Control => 6,
127 Self::Auth => 7,
128 Self::Sanitizer => 8,
129 Self::Sink => 9,
130 Self::Source => 10,
131 Self::Type => 11,
132 Self::Lifetime => 12,
133 Self::Concurrency => 13,
134 Self::Provenance => 14,
135 }
136 }
137}
138
139#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
141pub struct AnalysisFact {
142 pub id: FactId,
144 pub kind: FactKind,
146 pub span: AnalysisSourceSpan,
148 pub subject: u64,
150 pub object: Option<u64>,
152 pub payload: BTreeMap<String, String>,
154 pub provenance: Vec<FactId>,
156 pub confidence_bps: u16,
158 pub reason: String,
160}
161
162impl AnalysisFact {
163 #[must_use]
165 pub fn exact(id: FactId, kind: FactKind, span: AnalysisSourceSpan, subject: u64) -> Self {
166 Self {
167 id,
168 kind,
169 span,
170 subject,
171 object: None,
172 payload: BTreeMap::new(),
173 provenance: Vec::new(),
174 confidence_bps: 10_000,
175 reason: "exact-parser-fact".to_string(),
176 }
177 }
178
179 pub fn validate(&self) -> Result<(), AnalysisFactError> {
185 if !self.id.is_valid() {
186 return Err(AnalysisFactError::InvalidFactId { id: self.id });
187 }
188 self.span.validate("fact")?;
189 if self.confidence_bps > 10_000 {
190 return Err(AnalysisFactError::InvalidConfidence {
191 id: self.id,
192 confidence_bps: self.confidence_bps,
193 });
194 }
195 if self.confidence_bps < 10_000 && self.reason.trim().is_empty() {
196 return Err(AnalysisFactError::MissingInferenceReason { id: self.id });
197 }
198 if self.provenance.iter().any(|parent| *parent == self.id) {
199 return Err(AnalysisFactError::SelfProvenance { id: self.id });
200 }
201 for key in self.payload.keys() {
202 if key.trim().is_empty() {
203 return Err(AnalysisFactError::InvalidPayloadKey { id: self.id });
204 }
205 }
206 Ok(())
207 }
208
209 #[must_use]
211 pub fn shared_header(&self, producer: &str) -> SharedFactHeader {
212 let mut header = SharedFactHeader::new(
213 producer,
214 shared_kind_for_security_fact(self.kind),
215 self.id.0,
216 self.subject,
217 Soundness::Exact,
218 )
219 .with_span(self.span.file_id, self.span.start_byte, self.span.end_byte);
220 if let Some(object) = self.object {
221 header = header.with_object(object);
222 }
223 header
224 }
225}
226
227fn shared_kind_for_security_fact(kind: FactKind) -> SharedFactKind {
228 match kind {
229 FactKind::Source => SharedFactKind::Source,
230 FactKind::Sink => SharedFactKind::Sink,
231 FactKind::Sanitizer => SharedFactKind::Sanitizer,
232 FactKind::Dataflow => SharedFactKind::Taint,
233 FactKind::Edge | FactKind::Call | FactKind::Control => SharedFactKind::GraphEdge,
234 FactKind::Auth => SharedFactKind::Dominance,
235 FactKind::Lifetime => SharedFactKind::BorrowOrigin,
236 FactKind::Provenance => SharedFactKind::Witness,
237 FactKind::Type => SharedFactKind::Range,
238 FactKind::Node | FactKind::Symbol | FactKind::Concurrency => SharedFactKind::Taint,
239 }
240}
241
242#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
244pub struct AnalysisFactTable {
245 pub facts: Vec<AnalysisFact>,
247}
248
249impl AnalysisFactTable {
250 #[must_use]
252 pub fn new(facts: Vec<AnalysisFact>) -> Self {
253 Self { facts }
254 }
255
256 pub fn validate(&self) -> Result<(), AnalysisFactError> {
261 let mut ids = BTreeSet::new();
262 for fact in &self.facts {
263 fact.validate()?;
264 if !ids.insert(fact.id) {
265 return Err(AnalysisFactError::DuplicateFactId { id: fact.id });
266 }
267 }
268 for fact in &self.facts {
269 for parent in &fact.provenance {
270 if !ids.contains(parent) {
271 return Err(AnalysisFactError::MissingProvenanceParent {
272 id: fact.id,
273 parent: *parent,
274 });
275 }
276 }
277 }
278 Ok(())
279 }
280
281 pub fn to_columnar(&self) -> Result<AnalysisFactColumns, AnalysisFactError> {
286 self.validate()?;
287 let mut facts = self.facts.iter().collect::<Vec<_>>();
288 facts.sort_by_key(|fact| fact.id);
289 let mut columns = AnalysisFactColumns::default();
290 for fact in facts {
291 columns.ids.push(fact.id.0);
292 columns.kinds.push(fact.kind.tag());
293 columns.file_ids.push(fact.span.file_id);
294 columns.start_bytes.push(fact.span.start_byte);
295 columns.end_bytes.push(fact.span.end_byte);
296 columns.subjects.push(fact.subject);
297 columns.objects.push(fact.object.unwrap_or(0));
298 columns.confidence_bps.push(fact.confidence_bps);
299 columns
300 .payload_digests
301 .push(payload_digest(&fact.payload, &fact.reason));
302 columns
303 .provenance_offsets
304 .push(columns.provenance_ids.len() as u32);
305 columns
306 .provenance_ids
307 .extend(fact.provenance.iter().map(|parent| parent.0));
308 }
309 columns
310 .provenance_offsets
311 .push(columns.provenance_ids.len() as u32);
312 Ok(columns)
313 }
314
315 #[must_use]
317 pub fn contains(&self, id: FactId) -> bool {
318 self.facts.iter().any(|fact| fact.id == id)
319 }
320
321 #[must_use]
323 pub fn get(&self, id: FactId) -> Option<&AnalysisFact> {
324 self.facts.iter().find(|fact| fact.id == id)
325 }
326}
327
328#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
330pub struct AnalysisFactColumns {
331 pub ids: Vec<u64>,
333 pub kinds: Vec<u16>,
335 pub file_ids: Vec<u32>,
337 pub start_bytes: Vec<u32>,
339 pub end_bytes: Vec<u32>,
341 pub subjects: Vec<u64>,
343 pub objects: Vec<u64>,
345 pub confidence_bps: Vec<u16>,
347 pub payload_digests: Vec<[u8; 32]>,
349 pub provenance_offsets: Vec<u32>,
351 pub provenance_ids: Vec<u64>,
353}
354
355#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
357pub struct FindingProofStep {
358 pub fact_id: FactId,
360 pub span: AnalysisSourceSpan,
362 pub role: String,
364}
365
366impl FindingProofStep {
367 #[must_use]
369 pub fn new(fact_id: FactId, span: AnalysisSourceSpan, role: impl Into<String>) -> Self {
370 Self {
371 fact_id,
372 span,
373 role: role.into(),
374 }
375 }
376
377 fn validate(&self, table: &AnalysisFactTable) -> Result<(), AnalysisFactError> {
378 if !table.contains(self.fact_id) {
379 return Err(AnalysisFactError::FindingReferencesMissingFact {
380 finding_id: "<proof-step>".to_string(),
381 fact_id: self.fact_id,
382 });
383 }
384 if self.role.trim().is_empty() {
385 return Err(AnalysisFactError::InvalidProofRole {
386 fact_id: self.fact_id,
387 });
388 }
389 self.span.validate("finding proof step")
390 }
391}
392
393#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
395pub struct FindingProofBundle {
396 pub finding_id: String,
398 pub query_id: String,
400 pub backend_id: String,
402 pub evidence_digest: String,
404 pub precision_contract: PrecisionContract,
406 pub soundness: Soundness,
408 pub primitive_soundness: Vec<DynamicPrimitiveSoundness>,
410 pub fact_ids: Vec<FactId>,
412 pub proof_path: Vec<FindingProofStep>,
414 pub confidence_bps: u16,
416 pub reason: String,
418}
419
420#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
422pub struct SourceToSinkFindingRequest {
423 pub finding_id: String,
425 pub query_id: String,
427 pub backend_id: String,
429 pub evidence_digest: String,
431 pub precision_contract: PrecisionContract,
433 pub source_fact_id: FactId,
435 pub sink_fact_id: FactId,
437 pub path_fact_ids: Vec<FactId>,
439 pub sanitizer_fact_ids: Vec<FactId>,
441 pub query_hit: u32,
443 pub confidence_bps: u16,
445 pub reason: String,
447}
448
449impl FindingProofBundle {
450 pub fn validate_against(&self, table: &AnalysisFactTable) -> Result<(), AnalysisFactError> {
457 table.validate()?;
458 if self.finding_id.trim().is_empty() {
459 return Err(AnalysisFactError::InvalidFindingIdentity {
460 field: "finding_id",
461 });
462 }
463 if self.query_id.trim().is_empty() {
464 return Err(AnalysisFactError::InvalidFindingIdentity { field: "query_id" });
465 }
466 if self.backend_id.trim().is_empty() {
467 return Err(AnalysisFactError::InvalidFindingIdentity {
468 field: "backend_id",
469 });
470 }
471 if self.evidence_digest.trim().is_empty() {
472 return Err(AnalysisFactError::InvalidFindingIdentity {
473 field: "evidence_digest",
474 });
475 }
476 if self.reason.trim().is_empty() {
477 return Err(AnalysisFactError::InvalidFindingIdentity { field: "reason" });
478 }
479 if self.primitive_soundness.is_empty() {
480 return Err(AnalysisFactError::FindingHasNoSoundnessEvidence {
481 finding_id: self.finding_id.clone(),
482 });
483 }
484 let joined = validate_dynamic_pipeline(self.precision_contract, &self.primitive_soundness)
485 .map_err(|violation| AnalysisFactError::FindingSoundnessViolation {
486 finding_id: self.finding_id.clone(),
487 violation,
488 })?;
489 if joined != self.soundness {
490 return Err(AnalysisFactError::FindingSoundnessMismatch {
491 finding_id: self.finding_id.clone(),
492 declared: self.soundness,
493 computed: joined,
494 });
495 }
496 if self.confidence_bps > 10_000 {
497 return Err(AnalysisFactError::InvalidFindingConfidence {
498 finding_id: self.finding_id.clone(),
499 confidence_bps: self.confidence_bps,
500 });
501 }
502 if self.fact_ids.is_empty() {
503 return Err(AnalysisFactError::FindingHasNoFacts {
504 finding_id: self.finding_id.clone(),
505 });
506 }
507 if self.proof_path.is_empty() {
508 return Err(AnalysisFactError::FindingHasNoProofPath {
509 finding_id: self.finding_id.clone(),
510 });
511 }
512 for fact_id in &self.fact_ids {
513 if !table.contains(*fact_id) {
514 return Err(AnalysisFactError::FindingReferencesMissingFact {
515 finding_id: self.finding_id.clone(),
516 fact_id: *fact_id,
517 });
518 }
519 }
520 for step in &self.proof_path {
521 step.validate(table).map_err(|error| match error {
522 AnalysisFactError::FindingReferencesMissingFact { fact_id, .. } => {
523 AnalysisFactError::FindingReferencesMissingFact {
524 finding_id: self.finding_id.clone(),
525 fact_id,
526 }
527 }
528 other => other,
529 })?;
530 }
531 Ok(())
532 }
533}
534
535pub fn finding_from_sanitized_source_to_sink_query(
546 table: &AnalysisFactTable,
547 request: SourceToSinkFindingRequest,
548) -> Result<Option<FindingProofBundle>, AnalysisFactError> {
549 table.validate()?;
550 let source = require_fact_kind(table, request.source_fact_id, "source", &[FactKind::Source])?;
551 let sink = require_fact_kind(table, request.sink_fact_id, "sink", &[FactKind::Sink])?;
552 for fact_id in &request.path_fact_ids {
553 let _ = require_fact_kind(
554 table,
555 *fact_id,
556 "path",
557 &[
558 FactKind::Dataflow,
559 FactKind::Edge,
560 FactKind::Call,
561 FactKind::Control,
562 ],
563 )?;
564 }
565 for fact_id in &request.sanitizer_fact_ids {
566 let _ = require_fact_kind(table, *fact_id, "sanitizer", &[FactKind::Sanitizer])?;
567 }
568 if request.query_hit == 0 {
569 return Ok(None);
570 }
571 let primitive_soundness =
572 vec![
573 DynamicPrimitiveSoundness::new(request.query_id.clone(), Soundness::MayOver)
574 .with_sanitizer_filter(),
575 ];
576 let soundness = validate_dynamic_pipeline(request.precision_contract, &primitive_soundness)
577 .map_err(|violation| AnalysisFactError::FindingSoundnessViolation {
578 finding_id: request.finding_id.clone(),
579 violation,
580 })?;
581
582 let mut fact_ids = Vec::new();
583 push_unique_fact(&mut fact_ids, source.id);
584 for fact_id in &request.path_fact_ids {
585 push_unique_fact(&mut fact_ids, *fact_id);
586 }
587 for fact_id in &request.sanitizer_fact_ids {
588 push_unique_fact(&mut fact_ids, *fact_id);
589 }
590 push_unique_fact(&mut fact_ids, sink.id);
591
592 let mut proof_path = Vec::new();
593 proof_path.push(FindingProofStep::new(
594 source.id,
595 source.span.clone(),
596 "source",
597 ));
598 for fact_id in &request.path_fact_ids {
599 if let Some(fact) = table.get(*fact_id) {
600 proof_path.push(FindingProofStep::new(
601 fact.id,
602 fact.span.clone(),
603 "dataflow-path",
604 ));
605 }
606 }
607 for fact_id in &request.sanitizer_fact_ids {
608 if let Some(fact) = table.get(*fact_id) {
609 proof_path.push(FindingProofStep::new(
610 fact.id,
611 fact.span.clone(),
612 "sanitizer-considered",
613 ));
614 }
615 }
616 proof_path.push(FindingProofStep::new(sink.id, sink.span.clone(), "sink"));
617
618 let bundle = FindingProofBundle {
619 finding_id: request.finding_id,
620 query_id: request.query_id,
621 backend_id: request.backend_id,
622 evidence_digest: request.evidence_digest,
623 precision_contract: request.precision_contract,
624 soundness,
625 primitive_soundness,
626 fact_ids,
627 proof_path,
628 confidence_bps: request.confidence_bps,
629 reason: request.reason,
630 };
631 bundle.validate_against(table)?;
632 Ok(Some(bundle))
633}
634
635fn require_fact_kind<'a>(
636 table: &'a AnalysisFactTable,
637 fact_id: FactId,
638 role: &'static str,
639 expected: &'static [FactKind],
640) -> Result<&'a AnalysisFact, AnalysisFactError> {
641 let fact =
642 table
643 .get(fact_id)
644 .ok_or_else(|| AnalysisFactError::FindingReferencesMissingFact {
645 finding_id: format!("<{role}>"),
646 fact_id,
647 })?;
648 if !expected.contains(&fact.kind) {
649 return Err(AnalysisFactError::UnexpectedFactKind {
650 id: fact_id,
651 role,
652 expected: expected
653 .iter()
654 .map(|kind| format!("{kind:?}"))
655 .collect::<Vec<_>>()
656 .join("|"),
657 actual: fact.kind,
658 });
659 }
660 Ok(fact)
661}
662
663fn push_unique_fact(facts: &mut Vec<FactId>, fact_id: FactId) {
664 if !facts.contains(&fact_id) {
665 facts.push(fact_id);
666 }
667}
668
669#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
671pub enum AnalysisFactError {
672 #[error("invalid fact id {id:?}. Fix: assign non-zero stable fact ids before analysis.")]
674 InvalidFactId {
675 id: FactId,
677 },
678 #[error("duplicate fact id {id:?}. Fix: deduplicate facts before GPU columnar packing.")]
680 DuplicateFactId {
681 id: FactId,
683 },
684 #[error(
686 "{context} span has start_byte {start_byte} after end_byte {end_byte}. Fix: normalize parser spans before analysis."
687 )]
688 InvalidSpan {
689 context: String,
691 start_byte: u32,
693 end_byte: u32,
695 },
696 #[error(
698 "fact {id:?} confidence {confidence_bps} exceeds 10000. Fix: store confidence in basis points."
699 )]
700 InvalidConfidence {
701 id: FactId,
703 confidence_bps: u16,
705 },
706 #[error("fact {id:?} is inferred but has no reason. Fix: record why the fact is trusted.")]
708 MissingInferenceReason {
709 id: FactId,
711 },
712 #[error("fact {id:?} lists itself as provenance. Fix: remove cyclic fact derivation.")]
714 SelfProvenance {
715 id: FactId,
717 },
718 #[error("fact {id:?} has a blank payload key. Fix: normalize payload keys before packing.")]
720 InvalidPayloadKey {
721 id: FactId,
723 },
724 #[error(
726 "fact {id:?} references missing provenance parent {parent:?}. Fix: emit parent facts before derived facts."
727 )]
728 MissingProvenanceParent {
729 id: FactId,
731 parent: FactId,
733 },
734 #[error("finding field `{field}` is blank. Fix: findings must be fact-backed and replayable.")]
736 InvalidFindingIdentity {
737 field: &'static str,
739 },
740 #[error(
742 "finding `{finding_id}` confidence {confidence_bps} exceeds 10000. Fix: store confidence in basis points."
743 )]
744 InvalidFindingConfidence {
745 finding_id: String,
747 confidence_bps: u16,
749 },
750 #[error("finding `{finding_id}` references no facts. Fix: do not emit LLM-only findings.")]
752 FindingHasNoFacts {
753 finding_id: String,
755 },
756 #[error(
758 "finding `{finding_id}` has no proof path. Fix: include source-to-sink/auth path steps."
759 )]
760 FindingHasNoProofPath {
761 finding_id: String,
763 },
764 #[error(
766 "finding `{finding_id}` has no primitive soundness evidence. Fix: attach the query primitive ids and soundness tags before reporting."
767 )]
768 FindingHasNoSoundnessEvidence {
769 finding_id: String,
771 },
772 #[error(
774 "finding `{finding_id}` soundness evidence violates its precision contract: {violation:?}."
775 )]
776 FindingSoundnessViolation {
777 finding_id: String,
779 violation: DynamicSoundnessViolation,
781 },
782 #[error(
784 "finding `{finding_id}` declares soundness {declared:?} but primitive evidence computes {computed:?}. Fix: recompute soundness from primitive evidence."
785 )]
786 FindingSoundnessMismatch {
787 finding_id: String,
789 declared: Soundness,
791 computed: Soundness,
793 },
794 #[error(
796 "finding `{finding_id}` references missing fact {fact_id:?}. Fix: include all proof facts in the fact table."
797 )]
798 FindingReferencesMissingFact {
799 finding_id: String,
801 fact_id: FactId,
803 },
804 #[error("proof step for fact {fact_id:?} has a blank role. Fix: name each proof step role.")]
806 InvalidProofRole {
807 fact_id: FactId,
809 },
810 #[error(
812 "fact {id:?} has kind {actual:?} for role `{role}`, expected {expected}. Fix: normalize analysis facts before query proof emission."
813 )]
814 UnexpectedFactKind {
815 id: FactId,
817 role: &'static str,
819 expected: String,
821 actual: FactKind,
823 },
824}
825
826fn payload_digest(payload: &BTreeMap<String, String>, reason: &str) -> [u8; 32] {
827 let mut hasher = blake3::Hasher::new();
828 hash_field(&mut hasher, b"format", b"vyre-analysis-payload-v1");
829 for (key, value) in payload {
830 hash_field(&mut hasher, b"key", key.as_bytes());
831 hash_field(&mut hasher, b"value", value.as_bytes());
832 }
833 hash_field(&mut hasher, b"reason", reason.as_bytes());
834 *hasher.finalize().as_bytes()
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 fn span(offset: u32) -> AnalysisSourceSpan {
842 AnalysisSourceSpan::byte_range(7, offset, offset + 4)
843 }
844
845 fn fact(id: u64, kind: FactKind, subject: u64) -> AnalysisFact {
846 AnalysisFact::exact(FactId(id), kind, span(id as u32), subject)
847 }
848
849 fn table() -> AnalysisFactTable {
850 let mut source = fact(1, FactKind::Source, 10);
851 source
852 .payload
853 .insert("name".to_string(), "req.user".to_string());
854 let mut edge = fact(2, FactKind::Dataflow, 10);
855 edge.object = Some(20);
856 edge.provenance.push(FactId(1));
857 let mut sink = fact(3, FactKind::Sink, 20);
858 sink.payload
859 .insert("kind".to_string(), "sql.query".to_string());
860 AnalysisFactTable::new(vec![sink, edge, source])
861 }
862
863 #[test]
864 fn fact_table_to_columnar_sorts_by_fact_id_and_preserves_provenance_offsets() {
865 let columns = table()
866 .to_columnar()
867 .expect("Fix: canonical fact table should validate and pack");
868
869 assert_eq!(columns.ids, vec![1, 2, 3]);
870 assert_eq!(
871 columns.kinds,
872 vec![
873 FactKind::Source.tag(),
874 FactKind::Dataflow.tag(),
875 FactKind::Sink.tag()
876 ]
877 );
878 assert_eq!(columns.file_ids, vec![7, 7, 7]);
879 assert_eq!(columns.subjects, vec![10, 10, 20]);
880 assert_eq!(columns.objects, vec![0, 20, 0]);
881 assert_eq!(columns.provenance_offsets, vec![0, 0, 1, 1]);
882 assert_eq!(columns.provenance_ids, vec![1]);
883 }
884
885 #[test]
886 fn fact_table_rejects_duplicate_ids() {
887 let error = AnalysisFactTable::new(vec![
888 fact(1, FactKind::Source, 1),
889 fact(1, FactKind::Sink, 2),
890 ])
891 .validate()
892 .expect_err("Fix: duplicate fact ids must be rejected");
893
894 assert_eq!(error, AnalysisFactError::DuplicateFactId { id: FactId(1) });
895 }
896
897 #[test]
898 fn fact_table_rejects_missing_provenance_parent() {
899 let mut derived = fact(2, FactKind::Dataflow, 10);
900 derived.provenance.push(FactId(99));
901
902 let error = AnalysisFactTable::new(vec![fact(1, FactKind::Source, 10), derived])
903 .validate()
904 .expect_err("Fix: missing provenance parents must be rejected");
905
906 assert_eq!(
907 error,
908 AnalysisFactError::MissingProvenanceParent {
909 id: FactId(2),
910 parent: FactId(99),
911 }
912 );
913 }
914
915 #[test]
916 fn fact_table_rejects_inferred_fact_without_reason() {
917 let mut inferred = fact(4, FactKind::Auth, 40);
918 inferred.confidence_bps = 7500;
919 inferred.reason.clear();
920
921 let error = AnalysisFactTable::new(vec![inferred])
922 .validate()
923 .expect_err("Fix: inferred facts need a reason");
924
925 assert_eq!(
926 error,
927 AnalysisFactError::MissingInferenceReason { id: FactId(4) }
928 );
929 }
930
931 #[test]
932 fn finding_proof_bundle_validates_fact_backing_and_proof_path() {
933 let fact_table = table();
934 let bundle = FindingProofBundle {
935 finding_id: "finding.sql.source-to-sink.1".to_string(),
936 query_id: "vyre-libs::security::flows_to_with_sanitizer".to_string(),
937 backend_id: "cpu-ref".to_string(),
938 evidence_digest: "evidence:abc123".to_string(),
939 precision_contract: PrecisionContract::ZeroFalsePositive,
940 soundness: Soundness::Exact,
941 primitive_soundness: vec![DynamicPrimitiveSoundness::new(
942 "vyre-libs::security::sanitizer_dominates",
943 Soundness::Exact,
944 )],
945 fact_ids: vec![FactId(1), FactId(2), FactId(3)],
946 proof_path: vec![
947 FindingProofStep::new(FactId(1), span(1), "source"),
948 FindingProofStep::new(FactId(2), span(2), "dataflow-edge"),
949 FindingProofStep::new(FactId(3), span(3), "sink"),
950 ],
951 confidence_bps: 9800,
952 reason: "source reaches sql sink without sanitizer dominance".to_string(),
953 };
954
955 bundle
956 .validate_against(&fact_table)
957 .expect("Fix: fact-backed proof bundle should validate");
958 }
959
960 #[test]
961 fn finding_proof_bundle_rejects_llm_only_finding_without_facts() {
962 let fact_table = table();
963 let bundle = FindingProofBundle {
964 finding_id: "finding.llm-only".to_string(),
965 query_id: "manual".to_string(),
966 backend_id: "cpu-ref".to_string(),
967 evidence_digest: "evidence:abc123".to_string(),
968 precision_contract: PrecisionContract::ZeroFalsePositive,
969 soundness: Soundness::Exact,
970 primitive_soundness: vec![DynamicPrimitiveSoundness::new("manual", Soundness::Exact)],
971 fact_ids: Vec::new(),
972 proof_path: vec![FindingProofStep::new(FactId(1), span(1), "source")],
973 confidence_bps: 5000,
974 reason: "model guessed from code text".to_string(),
975 };
976
977 let error = bundle
978 .validate_against(&fact_table)
979 .expect_err("Fix: factless findings must be rejected");
980
981 assert_eq!(
982 error,
983 AnalysisFactError::FindingHasNoFacts {
984 finding_id: "finding.llm-only".to_string(),
985 }
986 );
987 }
988
989 #[test]
990 fn finding_proof_bundle_rejects_missing_fact_reference() {
991 let fact_table = table();
992 let bundle = FindingProofBundle {
993 finding_id: "finding.missing-fact".to_string(),
994 query_id: "vyre-libs::security::flows_to".to_string(),
995 backend_id: "cpu-ref".to_string(),
996 evidence_digest: "evidence:abc123".to_string(),
997 precision_contract: PrecisionContract::ZeroFalsePositive,
998 soundness: Soundness::Exact,
999 primitive_soundness: vec![DynamicPrimitiveSoundness::new(
1000 "vyre-libs::security::sanitizer_dominates",
1001 Soundness::Exact,
1002 )],
1003 fact_ids: vec![FactId(1), FactId(42)],
1004 proof_path: vec![FindingProofStep::new(FactId(1), span(1), "source")],
1005 confidence_bps: 9000,
1006 reason: "source reaches sink".to_string(),
1007 };
1008
1009 let error = bundle
1010 .validate_against(&fact_table)
1011 .expect_err("Fix: findings must not reference absent facts");
1012
1013 assert_eq!(
1014 error,
1015 AnalysisFactError::FindingReferencesMissingFact {
1016 finding_id: "finding.missing-fact".to_string(),
1017 fact_id: FactId(42),
1018 }
1019 );
1020 }
1021
1022 #[test]
1023 fn finding_proof_bundle_rejects_zero_false_positive_unfiltered_mayover() {
1024 let fact_table = table();
1025 let bundle = FindingProofBundle {
1026 finding_id: "finding.unfiltered-mayover".to_string(),
1027 query_id: "vyre-libs::security::flows_to".to_string(),
1028 backend_id: "cpu-ref".to_string(),
1029 evidence_digest: "evidence:abc123".to_string(),
1030 precision_contract: PrecisionContract::ZeroFalsePositive,
1031 soundness: Soundness::MayOver,
1032 primitive_soundness: vec![DynamicPrimitiveSoundness::new(
1033 "vyre-libs::security::flows_to",
1034 Soundness::MayOver,
1035 )],
1036 fact_ids: vec![FactId(1), FactId(2), FactId(3)],
1037 proof_path: vec![
1038 FindingProofStep::new(FactId(1), span(1), "source"),
1039 FindingProofStep::new(FactId(2), span(2), "dataflow-edge"),
1040 FindingProofStep::new(FactId(3), span(3), "sink"),
1041 ],
1042 confidence_bps: 9000,
1043 reason: "unfiltered over-approximate flow should not ship as zero-FP".to_string(),
1044 };
1045
1046 let error = bundle
1047 .validate_against(&fact_table)
1048 .expect_err("Fix: unfiltered MayOver must not validate as zero false positive");
1049
1050 match error {
1051 AnalysisFactError::FindingSoundnessViolation {
1052 finding_id,
1053 violation,
1054 } => {
1055 assert_eq!(finding_id, "finding.unfiltered-mayover");
1056 assert_eq!(violation.op_id, "vyre-libs::security::flows_to");
1057 assert_eq!(violation.soundness, Soundness::MayOver);
1058 assert_eq!(violation.contract, PrecisionContract::ZeroFalsePositive);
1059 }
1060 other => panic!("unexpected soundness validation error: {other:?}"),
1061 }
1062 }
1063
1064 #[test]
1065 fn finding_proof_bundle_rejects_declared_soundness_mismatch() {
1066 let fact_table = table();
1067 let bundle = FindingProofBundle {
1068 finding_id: "finding.soundness-mismatch".to_string(),
1069 query_id: "vyre-libs::security::flows_to_with_sanitizer".to_string(),
1070 backend_id: "cpu-ref".to_string(),
1071 evidence_digest: "evidence:abc123".to_string(),
1072 precision_contract: PrecisionContract::ZeroFalsePositive,
1073 soundness: Soundness::Exact,
1074 primitive_soundness: vec![DynamicPrimitiveSoundness::new(
1075 "vyre-libs::security::flows_to_with_sanitizer",
1076 Soundness::MayOver,
1077 )
1078 .with_sanitizer_filter()],
1079 fact_ids: vec![FactId(1), FactId(2), FactId(3)],
1080 proof_path: vec![
1081 FindingProofStep::new(FactId(1), span(1), "source"),
1082 FindingProofStep::new(FactId(2), span(2), "dataflow-edge"),
1083 FindingProofStep::new(FactId(3), span(3), "sink"),
1084 ],
1085 confidence_bps: 9000,
1086 reason: "declared exact despite MayOver primitive evidence".to_string(),
1087 };
1088
1089 let error = bundle
1090 .validate_against(&fact_table)
1091 .expect_err("Fix: declared soundness must match primitive evidence join");
1092
1093 assert_eq!(
1094 error,
1095 AnalysisFactError::FindingSoundnessMismatch {
1096 finding_id: "finding.soundness-mismatch".to_string(),
1097 declared: Soundness::Exact,
1098 computed: Soundness::MayOver,
1099 }
1100 );
1101 }
1102}