1use 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
18pub const SECURITY_RELATION_ANALYZER_SCHEMA_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, Copy, Eq, PartialEq)]
23pub enum SecurityRelationQueryFamily {
24 UnsanitizedSourceToSink,
26}
27
28impl SecurityRelationQueryFamily {
29 #[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#[derive(Debug, Clone, Copy)]
40pub struct GeneratedSecurityRelationAnalyzerSpec<'a> {
41 pub analyzer_id: &'a str,
43 pub family: SecurityRelationQueryFamily,
45 pub rule_id: &'a str,
47 pub backend_id: &'a str,
49 pub evidence_digest: &'a str,
51 pub precision_contract: PrecisionContract,
53 pub baseline_id: &'a str,
55}
56
57#[derive(Debug, Clone, Copy, Eq, PartialEq)]
59pub struct GeneratedSecurityRelationAnalyzerRunStats {
60 pub compile_time_ns: u64,
62 pub run_time_ns: u64,
64 pub memory_bytes: u64,
66 pub external_tuple_count: u32,
68 pub vyre_tuple_count: u32,
70 pub external_finding_count: u32,
72 pub vyre_finding_count: u32,
74}
75
76#[derive(Debug, Clone, Eq, PartialEq)]
78pub struct GeneratedSecurityRelationAnalyzerEvidence {
79 pub schema_version: u32,
81 pub analyzer_id: String,
83 pub query_family: &'static str,
85 pub baseline_id: String,
87 pub input_fact_count: u32,
89 pub source_tuple_count: u32,
91 pub sink_tuple_count: u32,
93 pub path_tuple_count: u32,
95 pub sanitizer_tuple_count: u32,
97 pub generated_tuple_count: u32,
99 pub generated_finding_count: u32,
101 pub external_tuple_count: u32,
103 pub vyre_tuple_count: u32,
105 pub external_finding_count: u32,
107 pub vyre_finding_count: u32,
109 pub compile_time_ns: u64,
111 pub run_time_ns: u64,
113 pub memory_bytes: u64,
115}
116
117#[derive(Debug, Clone, Eq, PartialEq)]
119pub struct GeneratedSecurityRelationAnalyzerReport {
120 pub evidence: GeneratedSecurityRelationAnalyzerEvidence,
122 pub findings: Vec<FindingProofBundle>,
124}
125
126#[derive(Debug, Clone, Eq, PartialEq)]
128pub enum SecurityRelationAnalyzerError {
129 Fact(AnalysisFactError),
131 BlankIdentity {
133 field: &'static str,
135 },
136 MissingAccounting {
138 field: &'static str,
140 },
141 BaselineMismatch {
143 baseline_id: String,
145 field: &'static str,
147 generated: u32,
149 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
187pub 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#[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}