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 fallow_types::workspace::WorkspaceDiagnostic;
12use serde::{Deserialize, Serialize, de::DeserializeOwned};
13
14pub const SECURITY_SCHEMA_VERSION: u32 = 8;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21pub enum SecuritySchemaVersion {
22 #[serde(rename = "1")]
24 V1,
25 #[serde(rename = "2")]
27 V2,
28 #[serde(rename = "3")]
30 V3,
31 #[serde(rename = "4")]
33 V4,
34 #[serde(rename = "5")]
36 V5,
37 #[serde(rename = "6")]
39 V6,
40 #[serde(rename = "7")]
42 V7,
43 #[serde(rename = "8")]
45 V8,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(rename_all = "kebab-case")]
54pub enum SecurityGateVerdict {
55 Pass,
57 Fail,
59}
60
61#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65pub struct SecurityGate<Mode> {
66 pub mode: Mode,
68 pub verdict: SecurityGateVerdict,
70 pub new_count: usize,
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77#[cfg_attr(
78 feature = "schema",
79 schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
80)]
81pub struct SecurityOutputConfig<Severity> {
82 pub rules: SecurityOutputRulesConfig<Severity>,
85 pub categories_include: Option<Vec<String>>,
88 pub categories_exclude: Option<Vec<String>>,
91}
92
93#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96pub struct SecurityOutputRulesConfig<Severity> {
97 pub security_client_server_leak: SecurityRuleSeverityConfig<Severity>,
99 pub security_sink: SecurityRuleSeverityConfig<Severity>,
101}
102
103#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct SecurityRuleSeverityConfig<Severity> {
107 pub configured: Severity,
110 pub effective: Severity,
112}
113
114#[derive(Debug, Clone, Serialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct SecurityOutput<Config, Gate> {
120 pub schema_version: SecuritySchemaVersion,
122 pub version: ToolVersion,
124 pub elapsed_ms: ElapsedMs,
126 pub config: Config,
128 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
130 pub meta: Option<Meta>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub gate: Option<Gate>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
139 pub security_findings: Vec<SecurityFinding>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
145 pub unresolved_edge_files: usize,
150 pub unresolved_callee_sites: usize,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163pub struct SecurityUnresolvedCalleeDiagnostics {
164 pub sampled: Vec<SecurityUnresolvedCalleeSample>,
166 pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
168 pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
170 pub sample_limit: usize,
172 pub top_files_limit: usize,
174}
175
176#[derive(Debug, Clone, Deserialize, Serialize)]
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179pub struct SecurityUnresolvedCalleeSample {
180 pub path: String,
182 pub line: u32,
184 pub col: u32,
186 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
188 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
190}
191
192#[derive(Debug, Clone, Deserialize, Serialize)]
194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
195pub struct SecurityUnresolvedCalleeTopFile {
196 pub path: String,
198 pub count: usize,
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize)]
204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
205pub struct SecurityUnresolvedCalleeReasonCount {
206 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
208 pub count: usize,
210}
211
212#[derive(Debug, Clone, Serialize)]
216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217pub struct SecuritySummaryOutput<Config, Gate> {
218 pub schema_version: SecuritySchemaVersion,
220 pub version: ToolVersion,
222 pub elapsed_ms: ElapsedMs,
224 pub config: Config,
226 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
228 pub meta: Option<Meta>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub gate: Option<Gate>,
232 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
235 pub summary: SecuritySummary,
237}
238
239#[derive(Deserialize)]
240#[serde(rename_all = "kebab-case")]
241enum SavedSecurityGateMode {
242 New,
243 NewlyReachable,
244}
245
246#[derive(Deserialize)]
247struct SavedSecurityEnvelope {
248 #[serde(rename = "version")]
249 _version: String,
250 #[serde(rename = "elapsed_ms")]
251 _elapsed_ms: u64,
252 #[serde(rename = "config")]
253 _config: SecurityOutputConfig<fallow_config::Severity>,
254 #[serde(rename = "_meta", default)]
255 meta: Option<serde_json::Value>,
256 #[serde(rename = "gate", default)]
257 _gate: Option<SecurityGate<SavedSecurityGateMode>>,
258}
259
260#[derive(Deserialize)]
261struct SavedSecurityFullPayload {
262 #[serde(rename = "security_findings")]
263 _security_findings: Vec<SecurityFinding>,
264 #[serde(rename = "attack_surface", default)]
265 _attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
266 #[serde(rename = "unresolved_edge_files")]
267 _unresolved_edge_files: usize,
268 #[serde(rename = "unresolved_callee_sites")]
269 _unresolved_callee_sites: usize,
270 #[serde(rename = "unresolved_callee_diagnostics", default)]
271 _unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
272}
273
274#[derive(Deserialize)]
275struct SavedSecuritySummaryPayload {
276 #[serde(rename = "summary")]
277 _summary: SecuritySummary,
278}
279
280pub fn validate_saved_security_envelope(value: &serde_json::Value) -> Result<(), String> {
285 let raw_version = value
286 .get("schema_version")
287 .and_then(serde_json::Value::as_str)
288 .ok_or_else(|| "saved security envelope is missing a string `schema_version`".to_owned())?;
289 let version = raw_version.parse::<u32>().map_err(|_| {
290 format!("saved security envelope has invalid schema version `{raw_version}`")
291 })?;
292 let current = SECURITY_SCHEMA_VERSION;
293 if version == 0 || version > current {
294 return Err(format!(
295 "unsupported saved security schema version {version}; this Fallow version supports versions 1 through {current}"
296 ));
297 }
298 if version < current {
299 return Ok(());
300 }
301
302 let envelope: SavedSecurityEnvelope = parse_saved_security(value, "envelope")?;
303 if let Some(meta) = envelope.meta {
304 let meta = meta
305 .as_object()
306 .ok_or_else(|| "saved security envelope field `_meta` must be an object".to_owned())?;
307 if let Some(type_aware) = meta.get("type_aware").filter(|value| !value.is_null()) {
308 parse_saved_security::<fallow_types::envelope::TypeAwareMeta>(
309 type_aware,
310 "type-aware metadata",
311 )?;
312 }
313 }
314
315 if value.get("security_findings").is_some() {
316 parse_saved_security::<SavedSecurityFullPayload>(value, "full payload")?;
317 } else if value.get("summary").is_some() {
318 parse_saved_security::<SavedSecuritySummaryPayload>(value, "summary payload")?;
319 } else {
320 return Err(
321 "saved security envelope is missing `security_findings` or `summary`".to_owned(),
322 );
323 }
324 Ok(())
325}
326
327fn parse_saved_security<T: DeserializeOwned>(
328 value: &serde_json::Value,
329 label: &str,
330) -> Result<T, String> {
331 T::deserialize(value).map_err(|error| {
332 format!("saved security {label} is incompatible with this Fallow version: {error}")
333 })
334}
335
336#[must_use]
338pub fn build_security_summary<Config, Gate>(
339 output: &SecurityOutput<Config, Gate>,
340) -> SecuritySummary {
341 let mut counts = SecuritySummaryCounts::default();
342
343 for finding in &output.security_findings {
344 counts.record(finding);
345 }
346
347 SecuritySummary {
348 security_findings: output.security_findings.len(),
349 by_severity: counts.severity,
350 by_category: counts.category,
351 by_reachability: counts.reachability,
352 by_runtime_state: counts.runtime_state,
353 unresolved_edge_files: output.unresolved_edge_files,
354 unresolved_callee_sites: output.unresolved_callee_sites,
355 attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
356 }
357}
358
359#[derive(Default)]
360struct SecuritySummaryCounts {
361 severity: SecuritySeverityCounts,
362 category: BTreeMap<String, usize>,
363 reachability: SecurityReachabilityCounts,
364 runtime_state: SecurityRuntimeStateCounts,
365}
366
367impl SecuritySummaryCounts {
368 fn record(&mut self, finding: &SecurityFinding) {
369 record_security_severity(finding.severity, &mut self.severity);
370 record_security_category(finding, &mut self.category);
371 record_security_reachability(finding, &mut self.reachability);
372 record_security_runtime_state(finding, &mut self.runtime_state);
373 }
374}
375
376fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
377 match severity {
378 SecuritySeverity::High => by_severity.high += 1,
379 SecuritySeverity::Medium => by_severity.medium += 1,
380 SecuritySeverity::Low => by_severity.low += 1,
381 }
382}
383
384fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
385 let category = finding
386 .category
387 .clone()
388 .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
389 *by_category.entry(category).or_insert(0) += 1;
390}
391
392fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
393 match kind {
394 SecurityFindingKind::ClientServerLeak => "client-server-leak",
395 SecurityFindingKind::TaintedSink => "tainted-sink",
396 }
397}
398
399fn record_security_reachability(
400 finding: &SecurityFinding,
401 by_reachability: &mut SecurityReachabilityCounts,
402) {
403 if finding.source_backed {
404 by_reachability.source_backed += 1;
405 }
406 let Some(reachability) = &finding.reachability else {
407 return;
408 };
409
410 if reachability.reachable_from_entry {
411 by_reachability.entry_reachable += 1;
412 }
413 if reachability.reachable_from_untrusted_source {
414 by_reachability.untrusted_source_reachable += 1;
415 }
416 if reachability.crosses_boundary {
417 by_reachability.crosses_boundary += 1;
418 }
419 match reachability.taint_confidence {
420 Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
421 Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
422 None => {}
423 }
424}
425
426fn record_security_runtime_state(
427 finding: &SecurityFinding,
428 by_runtime_state: &mut SecurityRuntimeStateCounts,
429) {
430 match finding.runtime.as_ref().map(|runtime| runtime.state) {
431 Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
432 Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
433 Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
434 Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
435 Some(SecurityRuntimeState::CoverageUnavailable) => {
436 by_runtime_state.coverage_unavailable += 1;
437 }
438 Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
439 None => by_runtime_state.not_collected += 1,
440 }
441}
442
443pub fn serialize_security_json_output<Config, Gate>(
449 output: SecurityOutput<Config, Gate>,
450 mode: RootEnvelopeMode,
451 analysis_run_id: Option<&str>,
452) -> Result<serde_json::Value, serde_json::Error>
453where
454 Config: Serialize,
455 Gate: Serialize,
456{
457 let mut value = serialize_named_json_output(output, "security", mode)?;
458 attach_telemetry_meta(&mut value, analysis_run_id);
459 Ok(value)
460}
461
462pub fn serialize_security_summary_json_output<Config, Gate>(
468 output: &SecurityOutput<Config, Gate>,
469 mode: RootEnvelopeMode,
470 analysis_run_id: Option<&str>,
471) -> Result<serde_json::Value, serde_json::Error>
472where
473 Config: Clone + Serialize,
474 Gate: Copy + Serialize,
475{
476 let summary = SecuritySummaryOutput {
477 schema_version: output.schema_version,
478 version: output.version.clone(),
479 elapsed_ms: output.elapsed_ms,
480 config: output.config.clone(),
481 meta: output.meta.clone(),
482 gate: output.gate,
483 workspace_diagnostics: output.workspace_diagnostics.clone(),
484 summary: build_security_summary(output),
485 };
486 let mut value = serialize_named_json_output(summary, "security", mode)?;
487 attach_telemetry_meta(&mut value, analysis_run_id);
488 Ok(value)
489}
490
491pub fn serialize_security_survivors_json_output(
497 output: SecuritySurvivorsOutput,
498 mode: RootEnvelopeMode,
499) -> Result<serde_json::Value, serde_json::Error> {
500 serialize_named_json_output(output, "security-survivors", mode)
501}
502
503pub fn serialize_security_blind_spots_json_output(
509 output: SecurityBlindSpotsOutput,
510 mode: RootEnvelopeMode,
511) -> Result<serde_json::Value, serde_json::Error> {
512 serialize_named_json_output(output, "security-blind-spots", mode)
513}
514
515#[derive(Debug, Clone, Deserialize, Serialize)]
517#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
518pub struct SecuritySummary {
519 pub security_findings: usize,
521 pub by_severity: SecuritySeverityCounts,
523 pub by_category: BTreeMap<String, usize>,
526 pub by_reachability: SecurityReachabilityCounts,
528 pub by_runtime_state: SecurityRuntimeStateCounts,
530 pub unresolved_edge_files: usize,
532 pub unresolved_callee_sites: usize,
534 pub attack_surface_entries: usize,
536}
537
538#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
540#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
541pub struct SecuritySeverityCounts {
542 pub high: usize,
544 pub medium: usize,
546 pub low: usize,
548}
549
550#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
553pub struct SecurityReachabilityCounts {
554 pub entry_reachable: usize,
556 pub untrusted_source_reachable: usize,
558 pub arg_level: usize,
560 pub module_level: usize,
562 pub crosses_boundary: usize,
564 pub source_backed: usize,
566}
567
568#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
570#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
571pub struct SecurityRuntimeStateCounts {
572 pub runtime_hot: usize,
574 pub runtime_cold: usize,
576 pub never_executed: usize,
578 pub low_traffic: usize,
580 pub coverage_unavailable: usize,
582 pub runtime_unknown: usize,
584 pub not_collected: usize,
586}
587
588#[derive(Debug, Clone, Copy, Serialize)]
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591pub enum SecuritySurvivorsSchemaVersion {
592 #[serde(rename = "2")]
594 V2,
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
599#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
600#[serde(rename_all = "kebab-case")]
601pub enum SecurityVerifierVerdictStatus {
602 Survivor,
604 Dismissed,
606 NeedsHumanReview,
608}
609
610#[derive(Debug, Clone, Deserialize, Serialize)]
612#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
613pub struct SecurityVerifierVerdict {
614 pub schema_version: String,
616 pub finding_id: String,
618 pub verdict: SecurityVerifierVerdictStatus,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub reason: Option<String>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub rationale: Option<String>,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub confidence: Option<String>,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub impact: Option<String>,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub fix_direction: Option<String>,
635}
636
637#[derive(Debug, Clone, Serialize)]
639#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
640pub struct SecuritySurvivorsOutput {
641 pub schema_version: SecuritySurvivorsSchemaVersion,
643 pub version: ToolVersion,
645 pub elapsed_ms: ElapsedMs,
647 #[serde(default, skip_serializing_if = "Vec::is_empty")]
649 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
650 pub summary: SecuritySurvivorsSummary,
652 pub survivors: BTreeMap<String, SecuritySurvivor>,
654 pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
657}
658
659#[derive(Debug, Clone, Copy, Serialize)]
661#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
662pub struct SecuritySurvivorsSummary {
663 pub candidates: usize,
665 pub verdicts: usize,
667 pub survivors: usize,
669 pub dismissed: usize,
671 pub needs_human_review: usize,
673 pub unverdicted: usize,
675}
676
677#[derive(Debug, Clone, Serialize)]
679#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
680pub struct SecuritySurvivor {
681 pub finding_id: String,
683 pub verdict: SecurityVerifierVerdictStatus,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub reason: Option<String>,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub rationale: Option<String>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub confidence: Option<String>,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub impact: Option<String>,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub fix_direction: Option<String>,
700 pub candidate: SecurityFinding,
702}
703
704#[derive(Debug, Clone, Copy, Serialize)]
706#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
707pub enum SecurityBlindSpotsSchemaVersion {
708 #[serde(rename = "1")]
710 V1,
711}
712
713#[derive(Debug, Clone, Serialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct SecurityBlindSpotsOutput {
717 pub schema_version: SecurityBlindSpotsSchemaVersion,
719 pub version: ToolVersion,
721 pub elapsed_ms: ElapsedMs,
723 #[serde(default, skip_serializing_if = "Vec::is_empty")]
725 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
726 pub summary: SecurityBlindSpotsSummary,
728 pub groups: Vec<SecurityBlindSpotGroup>,
730}
731
732#[derive(Debug, Clone, Copy, Serialize)]
734#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
735pub struct SecurityBlindSpotsSummary {
736 pub unresolved_edge_files: usize,
738 pub unresolved_callee_sites: usize,
740 pub sampled_callee_sites: usize,
742}
743
744#[derive(Debug, Clone, Serialize)]
746#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
747pub struct SecurityBlindSpotGroup {
748 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
750 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
752 pub sampled_count: usize,
754 pub files: Vec<SecurityBlindSpotFile>,
756 pub suggestion: String,
758}
759
760#[derive(Debug, Clone, Serialize)]
762#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
763pub struct SecurityBlindSpotFile {
764 pub path: String,
766 pub sampled_count: usize,
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use serde_json::json;
774
775 fn current_security_envelope() -> serde_json::Value {
776 json!({
777 "schema_version": "8",
778 "version": "test",
779 "elapsed_ms": 1,
780 "config": {
781 "rules": {
782 "security_client_server_leak": {
783 "configured": "warn",
784 "effective": "warn"
785 },
786 "security_sink": {
787 "configured": "warn",
788 "effective": "warn"
789 }
790 },
791 "categories_include": null,
792 "categories_exclude": null
793 },
794 "security_findings": [],
795 "unresolved_edge_files": 0,
796 "unresolved_callee_sites": 0
797 })
798 }
799
800 #[test]
801 fn security_summary_json_output_uses_security_root_contract() {
802 let output = SecurityOutput {
803 schema_version: SecuritySchemaVersion::V8,
804 version: ToolVersion("test".to_string()),
805 elapsed_ms: ElapsedMs(12),
806 config: json!({"rules": {}}),
807 meta: None,
808 gate: None::<()>,
809 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
810 std::path::Path::new("/project"),
811 std::path::PathBuf::from("package.json"),
812 fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
813 )],
814 security_findings: Vec::new(),
815 attack_surface: None,
816 unresolved_edge_files: 2,
817 unresolved_callee_sites: 3,
818 unresolved_callee_diagnostics: None,
819 };
820
821 let value = serialize_security_summary_json_output(&output, RootEnvelopeMode::Tagged, None)
822 .expect("security summary should serialize");
823
824 assert_eq!(value["kind"], "security");
825 assert_eq!(value["schema_version"], "8");
826 assert_eq!(value["summary"]["security_findings"], 0);
827 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
828 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
829 assert_eq!(value["workspace_diagnostics"][0]["path"], "package.json");
830 assert!(value.get("security_findings").is_none());
831 }
832
833 #[test]
834 fn saved_security_validator_accepts_current_full_and_summary_payloads() {
835 let full = current_security_envelope();
836 validate_saved_security_envelope(&full).expect("current full security envelope");
837
838 let mut summary = current_security_envelope();
839 summary
840 .as_object_mut()
841 .expect("security envelope")
842 .remove("security_findings");
843 summary
844 .as_object_mut()
845 .expect("security envelope")
846 .remove("unresolved_edge_files");
847 summary
848 .as_object_mut()
849 .expect("security envelope")
850 .remove("unresolved_callee_sites");
851 summary["summary"] = serde_json::to_value(SecuritySummary {
852 security_findings: 0,
853 by_severity: SecuritySeverityCounts::default(),
854 by_category: BTreeMap::new(),
855 by_reachability: SecurityReachabilityCounts::default(),
856 by_runtime_state: SecurityRuntimeStateCounts::default(),
857 unresolved_edge_files: 0,
858 unresolved_callee_sites: 0,
859 attack_surface_entries: 0,
860 })
861 .expect("security summary");
862 validate_saved_security_envelope(&summary).expect("current summary security envelope");
863 }
864
865 #[test]
866 fn saved_security_validator_accepts_legacy_v7_type_aware_payload() {
867 let mut envelope = current_security_envelope();
868 envelope["schema_version"] = json!("7");
869 envelope["_meta"] = json!({
870 "type_aware": {
871 "executed": true,
872 "protocol_version": 6,
873 "sidecar_version": "0.6.0",
874 "backend": "typescript-go",
875 "backend_version": "7.0.0-dev",
876 "selected_tsconfigs": ["tsconfig.json"],
877 "candidate_count": 1,
878 "confirmed_used_count": 0,
879 "contract_preserved_count": 0,
880 "no_static_references_count": 1,
881 "fix_eligible_count": 0,
882 "unresolved_count": 0,
883 "abstained_count": 0,
884 "abstention_reasons": {
885 "no_project": 0,
886 "ambiguous_project": 0,
887 "blocking_diagnostics": 0,
888 "svelte_virtual_module_exports": 0,
889 "unknown_symbol": 0,
890 "unsupported_syntax": 0,
891 "capacity": 0
892 },
893 "projects": [],
894 "warning_count": 0,
895 "warnings": [],
896 "elapsed_ms": 4,
897 "phase_timings_ms": {
898 "project_setup": 1,
899 "diagnostics": 1,
900 "symbol_scan": 2
901 }
902 }
903 });
904
905 validate_saved_security_envelope(&envelope)
906 .expect("legacy schema 7 type-aware metadata stays readable");
907 }
908
909 #[test]
910 fn saved_security_validator_rejects_malformed_current_payloads() {
911 let mut findings = current_security_envelope();
912 findings["security_findings"] = json!("not-an-array");
913 assert!(validate_saved_security_envelope(&findings).is_err());
914
915 let mut type_aware = current_security_envelope();
916 type_aware["_meta"] = json!({"type_aware": {"queries": "not-an-array"}});
917 assert!(validate_saved_security_envelope(&type_aware).is_err());
918 }
919
920 #[test]
921 fn saved_security_validator_accepts_legacy_and_rejects_future_schema() {
922 validate_saved_security_envelope(&json!({"schema_version": "7"}))
923 .expect("known legacy security schema");
924 let error = validate_saved_security_envelope(&json!({"schema_version": "9"}))
925 .expect_err("future security schema must fail closed");
926 assert!(error.contains("unsupported saved security schema version 9"));
927 }
928
929 fn assert_saved_security_parse_parity<T: DeserializeOwned>(
930 value: &serde_json::Value,
931 label: &str,
932 valid: bool,
933 ) {
934 let owned = serde_json::from_value::<T>(value.clone())
935 .map(|_| ())
936 .map_err(|error| {
937 format!("saved security {label} is incompatible with this Fallow version: {error}")
938 });
939 let borrowed = parse_saved_security::<T>(value, label).map(|_| ());
940 assert_eq!(borrowed.is_ok(), valid, "{label}: {borrowed:?}");
941 assert_eq!(
942 borrowed, owned,
943 "{label} must retain exact validation errors"
944 );
945 }
946
947 #[test]
948 fn saved_security_parsing_preserves_owned_value_contract() {
949 let mut full = current_security_envelope();
950 full["security_findings"] = json!([{
951 "finding_id": "security-fixture",
952 "kind": "tainted-sink",
953 "path": "src/[route]/café.ts",
954 "line": 12,
955 "col": 3,
956 "evidence": "quoted \"value\" and unicode café",
957 "severity": "low",
958 "trace": [],
959 "actions": [],
960 "candidate": fallow_types::results::SecurityCandidate::default()
961 }]);
962 full["future_field"] = json!({"nested": ["ignored", {"value": null}]});
963 assert_saved_security_parse_parity::<SavedSecurityEnvelope>(&full, "envelope", true);
964 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(&full, "full payload", true);
965 validate_saved_security_envelope(&full).expect("nonempty current payload remains valid");
966
967 for (field, invalid) in [
968 ("line", json!(-1)),
969 ("severity", json!("invalid")),
970 ("candidate", json!([])),
971 ] {
972 let mut malformed = full.clone();
973 malformed["security_findings"][0][field] = invalid;
974 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
975 &malformed,
976 "full payload",
977 false,
978 );
979 assert!(validate_saved_security_envelope(&malformed).is_err());
980 }
981 for malformed in [
982 serde_json::Value::Null,
983 json!({}),
984 json!({"security_findings": "not-an-array"}),
985 ] {
986 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
987 &malformed,
988 "full payload",
989 false,
990 );
991 }
992 let mut invalid_header = full.clone();
993 invalid_header["elapsed_ms"] = json!("invalid");
994 assert_saved_security_parse_parity::<SavedSecurityEnvelope>(
995 &invalid_header,
996 "envelope",
997 false,
998 );
999
1000 let summary = json!({"summary": {
1001 "security_findings": 1,
1002 "by_severity": {"high": 0, "medium": 0, "low": 1},
1003 "by_category": {"dangerous-html": 1},
1004 "by_reachability": SecurityReachabilityCounts::default(),
1005 "by_runtime_state": SecurityRuntimeStateCounts::default(),
1006 "unresolved_edge_files": 0,
1007 "unresolved_callee_sites": 0,
1008 "attack_surface_entries": 0
1009 }});
1010 assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
1011 &summary,
1012 "summary payload",
1013 true,
1014 );
1015 assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
1016 &json!({"summary": false}),
1017 "summary payload",
1018 false,
1019 );
1020 }
1021}