1use std::collections::BTreeMap;
4
5use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
6use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
7use fallow_types::results::{
8 SecurityAttackSurfaceEntry, SecurityFinding, SecurityFindingKind, SecurityRuntimeState,
9 SecuritySeverity, TaintConfidence,
10};
11use serde::{Deserialize, Serialize, de::DeserializeOwned};
12
13pub const SECURITY_SCHEMA_VERSION: u32 = 8;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20pub enum SecuritySchemaVersion {
21 #[serde(rename = "1")]
23 V1,
24 #[serde(rename = "2")]
26 V2,
27 #[serde(rename = "3")]
29 V3,
30 #[serde(rename = "4")]
32 V4,
33 #[serde(rename = "5")]
35 V5,
36 #[serde(rename = "6")]
38 V6,
39 #[serde(rename = "7")]
41 V7,
42 #[serde(rename = "8")]
44 V8,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52#[serde(rename_all = "kebab-case")]
53pub enum SecurityGateVerdict {
54 Pass,
56 Fail,
58}
59
60#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64pub struct SecurityGate<Mode> {
65 pub mode: Mode,
67 pub verdict: SecurityGateVerdict,
69 pub new_count: usize,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76#[cfg_attr(
77 feature = "schema",
78 schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
79)]
80pub struct SecurityOutputConfig<Severity> {
81 pub rules: SecurityOutputRulesConfig<Severity>,
84 pub categories_include: Option<Vec<String>>,
87 pub categories_exclude: Option<Vec<String>>,
90}
91
92#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95pub struct SecurityOutputRulesConfig<Severity> {
96 pub security_client_server_leak: SecurityRuleSeverityConfig<Severity>,
98 pub security_sink: SecurityRuleSeverityConfig<Severity>,
100}
101
102#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105pub struct SecurityRuleSeverityConfig<Severity> {
106 pub configured: Severity,
109 pub effective: Severity,
111}
112
113#[derive(Debug, Clone, Serialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct SecurityOutput<Config, Gate> {
119 pub schema_version: SecuritySchemaVersion,
121 pub version: ToolVersion,
123 pub elapsed_ms: ElapsedMs,
125 pub config: Config,
127 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
129 pub meta: Option<Meta>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub gate: Option<Gate>,
135 pub security_findings: Vec<SecurityFinding>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
141 pub unresolved_edge_files: usize,
146 pub unresolved_callee_sites: usize,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
154}
155
156#[derive(Debug, Clone, Deserialize, Serialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159pub struct SecurityUnresolvedCalleeDiagnostics {
160 pub sampled: Vec<SecurityUnresolvedCalleeSample>,
162 pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
164 pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
166 pub sample_limit: usize,
168 pub top_files_limit: usize,
170}
171
172#[derive(Debug, Clone, Deserialize, Serialize)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175pub struct SecurityUnresolvedCalleeSample {
176 pub path: String,
178 pub line: u32,
180 pub col: u32,
182 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
184 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
186}
187
188#[derive(Debug, Clone, Deserialize, Serialize)]
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191pub struct SecurityUnresolvedCalleeTopFile {
192 pub path: String,
194 pub count: usize,
196}
197
198#[derive(Debug, Clone, Deserialize, Serialize)]
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201pub struct SecurityUnresolvedCalleeReasonCount {
202 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
204 pub count: usize,
206}
207
208#[derive(Debug, Clone, Serialize)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213pub struct SecuritySummaryOutput<Config, Gate> {
214 pub schema_version: SecuritySchemaVersion,
216 pub version: ToolVersion,
218 pub elapsed_ms: ElapsedMs,
220 pub config: Config,
222 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
224 pub meta: Option<Meta>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub gate: Option<Gate>,
228 pub summary: SecuritySummary,
230}
231
232#[derive(Deserialize)]
233#[serde(rename_all = "kebab-case")]
234enum SavedSecurityGateMode {
235 New,
236 NewlyReachable,
237}
238
239#[derive(Deserialize)]
240struct SavedSecurityEnvelope {
241 #[serde(rename = "version")]
242 _version: String,
243 #[serde(rename = "elapsed_ms")]
244 _elapsed_ms: u64,
245 #[serde(rename = "config")]
246 _config: SecurityOutputConfig<fallow_config::Severity>,
247 #[serde(rename = "_meta", default)]
248 meta: Option<serde_json::Value>,
249 #[serde(rename = "gate", default)]
250 _gate: Option<SecurityGate<SavedSecurityGateMode>>,
251}
252
253#[derive(Deserialize)]
254struct SavedSecurityFullPayload {
255 #[serde(rename = "security_findings")]
256 _security_findings: Vec<SecurityFinding>,
257 #[serde(rename = "attack_surface", default)]
258 _attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
259 #[serde(rename = "unresolved_edge_files")]
260 _unresolved_edge_files: usize,
261 #[serde(rename = "unresolved_callee_sites")]
262 _unresolved_callee_sites: usize,
263 #[serde(rename = "unresolved_callee_diagnostics", default)]
264 _unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
265}
266
267#[derive(Deserialize)]
268struct SavedSecuritySummaryPayload {
269 #[serde(rename = "summary")]
270 _summary: SecuritySummary,
271}
272
273pub fn validate_saved_security_envelope(value: &serde_json::Value) -> Result<(), String> {
278 let raw_version = value
279 .get("schema_version")
280 .and_then(serde_json::Value::as_str)
281 .ok_or_else(|| "saved security envelope is missing a string `schema_version`".to_owned())?;
282 let version = raw_version.parse::<u32>().map_err(|_| {
283 format!("saved security envelope has invalid schema version `{raw_version}`")
284 })?;
285 let current = SECURITY_SCHEMA_VERSION;
286 if version == 0 || version > current {
287 return Err(format!(
288 "unsupported saved security schema version {version}; this Fallow version supports versions 1 through {current}"
289 ));
290 }
291 if version < current {
292 return Ok(());
293 }
294
295 let envelope: SavedSecurityEnvelope = parse_saved_security(value, "envelope")?;
296 if let Some(meta) = envelope.meta {
297 let meta = meta
298 .as_object()
299 .ok_or_else(|| "saved security envelope field `_meta` must be an object".to_owned())?;
300 if let Some(type_aware) = meta.get("type_aware").filter(|value| !value.is_null()) {
301 parse_saved_security::<fallow_types::envelope::TypeAwareMeta>(
302 type_aware,
303 "type-aware metadata",
304 )?;
305 }
306 }
307
308 if value.get("security_findings").is_some() {
309 parse_saved_security::<SavedSecurityFullPayload>(value, "full payload")?;
310 } else if value.get("summary").is_some() {
311 parse_saved_security::<SavedSecuritySummaryPayload>(value, "summary payload")?;
312 } else {
313 return Err(
314 "saved security envelope is missing `security_findings` or `summary`".to_owned(),
315 );
316 }
317 Ok(())
318}
319
320fn parse_saved_security<T: DeserializeOwned>(
321 value: &serde_json::Value,
322 label: &str,
323) -> Result<T, String> {
324 serde_json::from_value(value.clone()).map_err(|error| {
325 format!("saved security {label} is incompatible with this Fallow version: {error}")
326 })
327}
328
329#[must_use]
331pub fn build_security_summary<Config, Gate>(
332 output: &SecurityOutput<Config, Gate>,
333) -> SecuritySummary {
334 let mut counts = SecuritySummaryCounts::default();
335
336 for finding in &output.security_findings {
337 counts.record(finding);
338 }
339
340 SecuritySummary {
341 security_findings: output.security_findings.len(),
342 by_severity: counts.severity,
343 by_category: counts.category,
344 by_reachability: counts.reachability,
345 by_runtime_state: counts.runtime_state,
346 unresolved_edge_files: output.unresolved_edge_files,
347 unresolved_callee_sites: output.unresolved_callee_sites,
348 attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
349 }
350}
351
352#[derive(Default)]
353struct SecuritySummaryCounts {
354 severity: SecuritySeverityCounts,
355 category: BTreeMap<String, usize>,
356 reachability: SecurityReachabilityCounts,
357 runtime_state: SecurityRuntimeStateCounts,
358}
359
360impl SecuritySummaryCounts {
361 fn record(&mut self, finding: &SecurityFinding) {
362 record_security_severity(finding.severity, &mut self.severity);
363 record_security_category(finding, &mut self.category);
364 record_security_reachability(finding, &mut self.reachability);
365 record_security_runtime_state(finding, &mut self.runtime_state);
366 }
367}
368
369fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
370 match severity {
371 SecuritySeverity::High => by_severity.high += 1,
372 SecuritySeverity::Medium => by_severity.medium += 1,
373 SecuritySeverity::Low => by_severity.low += 1,
374 }
375}
376
377fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
378 let category = finding
379 .category
380 .clone()
381 .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
382 *by_category.entry(category).or_insert(0) += 1;
383}
384
385fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
386 match kind {
387 SecurityFindingKind::ClientServerLeak => "client-server-leak",
388 SecurityFindingKind::TaintedSink => "tainted-sink",
389 }
390}
391
392fn record_security_reachability(
393 finding: &SecurityFinding,
394 by_reachability: &mut SecurityReachabilityCounts,
395) {
396 if finding.source_backed {
397 by_reachability.source_backed += 1;
398 }
399 let Some(reachability) = &finding.reachability else {
400 return;
401 };
402
403 if reachability.reachable_from_entry {
404 by_reachability.entry_reachable += 1;
405 }
406 if reachability.reachable_from_untrusted_source {
407 by_reachability.untrusted_source_reachable += 1;
408 }
409 if reachability.crosses_boundary {
410 by_reachability.crosses_boundary += 1;
411 }
412 match reachability.taint_confidence {
413 Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
414 Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
415 None => {}
416 }
417}
418
419fn record_security_runtime_state(
420 finding: &SecurityFinding,
421 by_runtime_state: &mut SecurityRuntimeStateCounts,
422) {
423 match finding.runtime.as_ref().map(|runtime| runtime.state) {
424 Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
425 Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
426 Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
427 Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
428 Some(SecurityRuntimeState::CoverageUnavailable) => {
429 by_runtime_state.coverage_unavailable += 1;
430 }
431 Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
432 None => by_runtime_state.not_collected += 1,
433 }
434}
435
436pub fn serialize_security_json_output<Config, Gate>(
442 output: SecurityOutput<Config, Gate>,
443 mode: RootEnvelopeMode,
444 analysis_run_id: Option<&str>,
445) -> Result<serde_json::Value, serde_json::Error>
446where
447 Config: Serialize,
448 Gate: Serialize,
449{
450 let mut value = serialize_named_json_output(output, "security", mode)?;
451 attach_telemetry_meta(&mut value, analysis_run_id);
452 Ok(value)
453}
454
455pub fn serialize_security_summary_json_output<Config, Gate>(
461 output: &SecurityOutput<Config, Gate>,
462 mode: RootEnvelopeMode,
463 analysis_run_id: Option<&str>,
464) -> Result<serde_json::Value, serde_json::Error>
465where
466 Config: Clone + Serialize,
467 Gate: Copy + Serialize,
468{
469 let summary = SecuritySummaryOutput {
470 schema_version: output.schema_version,
471 version: output.version.clone(),
472 elapsed_ms: output.elapsed_ms,
473 config: output.config.clone(),
474 meta: output.meta.clone(),
475 gate: output.gate,
476 summary: build_security_summary(output),
477 };
478 let mut value = serialize_named_json_output(summary, "security", mode)?;
479 attach_telemetry_meta(&mut value, analysis_run_id);
480 Ok(value)
481}
482
483pub fn serialize_security_survivors_json_output(
489 output: SecuritySurvivorsOutput,
490 mode: RootEnvelopeMode,
491) -> Result<serde_json::Value, serde_json::Error> {
492 serialize_named_json_output(output, "security-survivors", mode)
493}
494
495pub fn serialize_security_blind_spots_json_output(
501 output: SecurityBlindSpotsOutput,
502 mode: RootEnvelopeMode,
503) -> Result<serde_json::Value, serde_json::Error> {
504 serialize_named_json_output(output, "security-blind-spots", mode)
505}
506
507#[derive(Debug, Clone, Deserialize, Serialize)]
509#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
510pub struct SecuritySummary {
511 pub security_findings: usize,
513 pub by_severity: SecuritySeverityCounts,
515 pub by_category: BTreeMap<String, usize>,
518 pub by_reachability: SecurityReachabilityCounts,
520 pub by_runtime_state: SecurityRuntimeStateCounts,
522 pub unresolved_edge_files: usize,
524 pub unresolved_callee_sites: usize,
526 pub attack_surface_entries: usize,
528}
529
530#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
532#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
533pub struct SecuritySeverityCounts {
534 pub high: usize,
536 pub medium: usize,
538 pub low: usize,
540}
541
542#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
544#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
545pub struct SecurityReachabilityCounts {
546 pub entry_reachable: usize,
548 pub untrusted_source_reachable: usize,
550 pub arg_level: usize,
552 pub module_level: usize,
554 pub crosses_boundary: usize,
556 pub source_backed: usize,
558}
559
560#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
563pub struct SecurityRuntimeStateCounts {
564 pub runtime_hot: usize,
566 pub runtime_cold: usize,
568 pub never_executed: usize,
570 pub low_traffic: usize,
572 pub coverage_unavailable: usize,
574 pub runtime_unknown: usize,
576 pub not_collected: usize,
578}
579
580#[derive(Debug, Clone, Copy, Serialize)]
582#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
583pub enum SecuritySurvivorsSchemaVersion {
584 #[serde(rename = "2")]
586 V2,
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
591#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
592#[serde(rename_all = "kebab-case")]
593pub enum SecurityVerifierVerdictStatus {
594 Survivor,
596 Dismissed,
598 NeedsHumanReview,
600}
601
602#[derive(Debug, Clone, Deserialize, Serialize)]
604#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
605pub struct SecurityVerifierVerdict {
606 pub schema_version: String,
608 pub finding_id: String,
610 pub verdict: SecurityVerifierVerdictStatus,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub reason: Option<String>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub rationale: Option<String>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub confidence: Option<String>,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub impact: Option<String>,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub fix_direction: Option<String>,
627}
628
629#[derive(Debug, Clone, Serialize)]
631#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
632pub struct SecuritySurvivorsOutput {
633 pub schema_version: SecuritySurvivorsSchemaVersion,
635 pub version: ToolVersion,
637 pub elapsed_ms: ElapsedMs,
639 pub summary: SecuritySurvivorsSummary,
641 pub survivors: BTreeMap<String, SecuritySurvivor>,
643 pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
646}
647
648#[derive(Debug, Clone, Copy, Serialize)]
650#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
651pub struct SecuritySurvivorsSummary {
652 pub candidates: usize,
654 pub verdicts: usize,
656 pub survivors: usize,
658 pub dismissed: usize,
660 pub needs_human_review: usize,
662 pub unverdicted: usize,
664}
665
666#[derive(Debug, Clone, Serialize)]
668#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
669pub struct SecuritySurvivor {
670 pub finding_id: String,
672 pub verdict: SecurityVerifierVerdictStatus,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub reason: Option<String>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub rationale: Option<String>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub confidence: Option<String>,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub impact: Option<String>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub fix_direction: Option<String>,
689 pub candidate: SecurityFinding,
691}
692
693#[derive(Debug, Clone, Copy, Serialize)]
695#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
696pub enum SecurityBlindSpotsSchemaVersion {
697 #[serde(rename = "1")]
699 V1,
700}
701
702#[derive(Debug, Clone, Serialize)]
704#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
705pub struct SecurityBlindSpotsOutput {
706 pub schema_version: SecurityBlindSpotsSchemaVersion,
708 pub version: ToolVersion,
710 pub elapsed_ms: ElapsedMs,
712 pub summary: SecurityBlindSpotsSummary,
714 pub groups: Vec<SecurityBlindSpotGroup>,
716}
717
718#[derive(Debug, Clone, Copy, Serialize)]
720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
721pub struct SecurityBlindSpotsSummary {
722 pub unresolved_edge_files: usize,
724 pub unresolved_callee_sites: usize,
726 pub sampled_callee_sites: usize,
728}
729
730#[derive(Debug, Clone, Serialize)]
732#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
733pub struct SecurityBlindSpotGroup {
734 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
736 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
738 pub sampled_count: usize,
740 pub files: Vec<SecurityBlindSpotFile>,
742 pub suggestion: String,
744}
745
746#[derive(Debug, Clone, Serialize)]
748#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
749pub struct SecurityBlindSpotFile {
750 pub path: String,
752 pub sampled_count: usize,
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use serde_json::json;
760
761 fn current_security_envelope() -> serde_json::Value {
762 json!({
763 "schema_version": "8",
764 "version": "test",
765 "elapsed_ms": 1,
766 "config": {
767 "rules": {
768 "security_client_server_leak": {
769 "configured": "warn",
770 "effective": "warn"
771 },
772 "security_sink": {
773 "configured": "warn",
774 "effective": "warn"
775 }
776 },
777 "categories_include": null,
778 "categories_exclude": null
779 },
780 "security_findings": [],
781 "unresolved_edge_files": 0,
782 "unresolved_callee_sites": 0
783 })
784 }
785
786 #[test]
787 fn security_summary_json_output_uses_security_root_contract() {
788 let output = SecurityOutput {
789 schema_version: SecuritySchemaVersion::V8,
790 version: ToolVersion("test".to_string()),
791 elapsed_ms: ElapsedMs(12),
792 config: json!({"rules": {}}),
793 meta: None,
794 gate: None::<()>,
795 security_findings: Vec::new(),
796 attack_surface: None,
797 unresolved_edge_files: 2,
798 unresolved_callee_sites: 3,
799 unresolved_callee_diagnostics: None,
800 };
801
802 let value = serialize_security_summary_json_output(&output, RootEnvelopeMode::Tagged, None)
803 .expect("security summary should serialize");
804
805 assert_eq!(value["kind"], "security");
806 assert_eq!(value["schema_version"], "8");
807 assert_eq!(value["summary"]["security_findings"], 0);
808 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
809 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
810 assert!(value.get("security_findings").is_none());
811 }
812
813 #[test]
814 fn saved_security_validator_accepts_current_full_and_summary_payloads() {
815 let full = current_security_envelope();
816 validate_saved_security_envelope(&full).expect("current full security envelope");
817
818 let mut summary = current_security_envelope();
819 summary
820 .as_object_mut()
821 .expect("security envelope")
822 .remove("security_findings");
823 summary
824 .as_object_mut()
825 .expect("security envelope")
826 .remove("unresolved_edge_files");
827 summary
828 .as_object_mut()
829 .expect("security envelope")
830 .remove("unresolved_callee_sites");
831 summary["summary"] = serde_json::to_value(SecuritySummary {
832 security_findings: 0,
833 by_severity: SecuritySeverityCounts::default(),
834 by_category: BTreeMap::new(),
835 by_reachability: SecurityReachabilityCounts::default(),
836 by_runtime_state: SecurityRuntimeStateCounts::default(),
837 unresolved_edge_files: 0,
838 unresolved_callee_sites: 0,
839 attack_surface_entries: 0,
840 })
841 .expect("security summary");
842 validate_saved_security_envelope(&summary).expect("current summary security envelope");
843 }
844
845 #[test]
846 fn saved_security_validator_rejects_malformed_current_payloads() {
847 let mut findings = current_security_envelope();
848 findings["security_findings"] = json!("not-an-array");
849 assert!(validate_saved_security_envelope(&findings).is_err());
850
851 let mut type_aware = current_security_envelope();
852 type_aware["_meta"] = json!({"type_aware": {"queries": "not-an-array"}});
853 assert!(validate_saved_security_envelope(&type_aware).is_err());
854 }
855
856 #[test]
857 fn saved_security_validator_accepts_legacy_and_rejects_future_schema() {
858 validate_saved_security_envelope(&json!({"schema_version": "7"}))
859 .expect("known legacy security schema");
860 let error = validate_saved_security_envelope(&json!({"schema_version": "9"}))
861 .expect_err("future security schema must fail closed");
862 assert!(error.contains("unsupported saved security schema version 9"));
863 }
864}