1use std::collections::BTreeMap;
4
5use crate::root_envelopes::{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>,
88 pub categories_include: Option<Vec<String>>,
91 pub categories_exclude: Option<Vec<String>>,
94}
95
96#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
98#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
99pub struct SecurityOutputRulesConfig<Severity> {
100 pub security_client_server_leak: SecurityRuleSeverityConfig<Severity>,
102 pub security_sink: SecurityRuleSeverityConfig<Severity>,
104}
105
106#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
108#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
109pub struct SecurityRuleSeverityConfig<Severity> {
110 pub configured: Severity,
114 pub effective: Severity,
118}
119
120#[derive(Debug, Clone, Serialize)]
124#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
125pub struct SecurityOutput<Config, Gate> {
126 pub schema_version: SecuritySchemaVersion,
128 pub version: ToolVersion,
130 pub elapsed_ms: ElapsedMs,
132 pub config: Config,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub gate_outcomes: Option<crate::GateOutcomes>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub request_outcomes: Option<crate::RequestOutcomes>,
152 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
154 pub meta: Option<Meta>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub gate: Option<Gate>,
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
163 pub security_findings: Vec<SecurityFinding>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
169 pub unresolved_edge_files: usize,
174 pub unresolved_callee_sites: usize,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
182}
183
184#[derive(Debug, Clone, Deserialize, Serialize)]
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187pub struct SecurityUnresolvedCalleeDiagnostics {
188 pub sampled: Vec<SecurityUnresolvedCalleeSample>,
190 pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
192 pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
194 pub sample_limit: usize,
196 pub top_files_limit: usize,
198}
199
200#[derive(Debug, Clone, Deserialize, Serialize)]
202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203pub struct SecurityUnresolvedCalleeSample {
204 pub path: String,
206 pub line: u32,
208 pub col: u32,
210 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
212 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
214}
215
216#[derive(Debug, Clone, Deserialize, Serialize)]
218#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
219pub struct SecurityUnresolvedCalleeTopFile {
220 pub path: String,
222 pub count: usize,
224}
225
226#[derive(Debug, Clone, Deserialize, Serialize)]
228#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
229pub struct SecurityUnresolvedCalleeReasonCount {
230 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
232 pub count: usize,
234}
235
236#[derive(Debug, Clone, Serialize)]
240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
241pub struct SecuritySummaryOutput<Config, Gate> {
242 pub schema_version: SecuritySchemaVersion,
244 pub version: ToolVersion,
246 pub elapsed_ms: ElapsedMs,
248 pub config: Config,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub gate_outcomes: Option<crate::GateOutcomes>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub request_outcomes: Option<crate::RequestOutcomes>,
268 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
270 pub meta: Option<Meta>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub gate: Option<Gate>,
274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
276 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
277 pub summary: SecuritySummary,
279}
280
281#[derive(Deserialize)]
282#[serde(rename_all = "kebab-case")]
283enum SavedSecurityGateMode {
284 New,
285 NewlyReachable,
286}
287
288#[derive(Deserialize)]
289struct SavedSecurityEnvelope {
290 #[serde(rename = "version")]
291 _version: String,
292 #[serde(rename = "elapsed_ms")]
293 _elapsed_ms: u64,
294 #[serde(rename = "config")]
295 _config: SecurityOutputConfig<fallow_config::Severity>,
296 #[serde(rename = "_meta", default)]
297 meta: Option<serde_json::Value>,
298 #[serde(rename = "gate", default)]
299 _gate: Option<SecurityGate<SavedSecurityGateMode>>,
300}
301
302#[derive(Deserialize)]
303struct SavedSecurityFullPayload {
304 #[serde(rename = "security_findings")]
305 _security_findings: Vec<SecurityFinding>,
306 #[serde(rename = "attack_surface", default)]
307 _attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
308 #[serde(rename = "unresolved_edge_files")]
309 _unresolved_edge_files: usize,
310 #[serde(rename = "unresolved_callee_sites")]
311 _unresolved_callee_sites: usize,
312 #[serde(rename = "unresolved_callee_diagnostics", default)]
313 _unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
314}
315
316#[derive(Deserialize)]
317struct SavedSecuritySummaryPayload {
318 #[serde(rename = "summary")]
319 _summary: SecuritySummary,
320}
321
322pub fn validate_saved_security_envelope(value: &serde_json::Value) -> Result<(), String> {
327 let raw_version = value
328 .get("schema_version")
329 .and_then(serde_json::Value::as_str)
330 .ok_or_else(|| "saved security envelope is missing a string `schema_version`".to_owned())?;
331 let version = raw_version.parse::<u32>().map_err(|_| {
332 format!("saved security envelope has invalid schema version `{raw_version}`")
333 })?;
334 let current = SECURITY_SCHEMA_VERSION;
335 if version == 0 || version > current {
336 return Err(format!(
337 "unsupported saved security schema version {version}; this Fallow version supports versions 1 through {current}"
338 ));
339 }
340 if version < current {
341 return Ok(());
342 }
343
344 let envelope: SavedSecurityEnvelope = parse_saved_security(value, "envelope")?;
345 if let Some(meta) = envelope.meta {
346 let meta = meta
347 .as_object()
348 .ok_or_else(|| "saved security envelope field `_meta` must be an object".to_owned())?;
349 if let Some(type_aware) = meta.get("type_aware").filter(|value| !value.is_null()) {
350 parse_saved_security::<fallow_types::envelope::TypeAwareMeta>(
351 type_aware,
352 "type-aware metadata",
353 )?;
354 }
355 }
356
357 if value.get("security_findings").is_some() {
358 parse_saved_security::<SavedSecurityFullPayload>(value, "full payload")?;
359 } else if value.get("summary").is_some() {
360 parse_saved_security::<SavedSecuritySummaryPayload>(value, "summary payload")?;
361 } else {
362 return Err(
363 "saved security envelope is missing `security_findings` or `summary`".to_owned(),
364 );
365 }
366 Ok(())
367}
368
369fn parse_saved_security<T: DeserializeOwned>(
370 value: &serde_json::Value,
371 label: &str,
372) -> Result<T, String> {
373 T::deserialize(value).map_err(|error| {
374 format!("saved security {label} is incompatible with this Fallow version: {error}")
375 })
376}
377
378#[must_use]
380pub fn build_security_summary<Config, Gate>(
381 output: &SecurityOutput<Config, Gate>,
382) -> SecuritySummary {
383 let mut counts = SecuritySummaryCounts::default();
384
385 for finding in &output.security_findings {
386 counts.record(finding);
387 }
388
389 SecuritySummary {
390 security_findings: output.security_findings.len(),
391 by_severity: counts.severity,
392 by_category: counts.category,
393 by_reachability: counts.reachability,
394 by_runtime_state: counts.runtime_state,
395 unresolved_edge_files: output.unresolved_edge_files,
396 unresolved_callee_sites: output.unresolved_callee_sites,
397 attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
398 }
399}
400
401#[derive(Default)]
402struct SecuritySummaryCounts {
403 severity: SecuritySeverityCounts,
404 category: BTreeMap<String, usize>,
405 reachability: SecurityReachabilityCounts,
406 runtime_state: SecurityRuntimeStateCounts,
407}
408
409impl SecuritySummaryCounts {
410 fn record(&mut self, finding: &SecurityFinding) {
411 record_security_severity(finding.severity, &mut self.severity);
412 record_security_category(finding, &mut self.category);
413 record_security_reachability(finding, &mut self.reachability);
414 record_security_runtime_state(finding, &mut self.runtime_state);
415 }
416}
417
418fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
419 match severity {
420 SecuritySeverity::High => by_severity.high += 1,
421 SecuritySeverity::Medium => by_severity.medium += 1,
422 SecuritySeverity::Low => by_severity.low += 1,
423 }
424}
425
426fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
427 let category = finding
428 .category
429 .clone()
430 .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
431 *by_category.entry(category).or_insert(0) += 1;
432}
433
434fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
435 match kind {
436 SecurityFindingKind::ClientServerLeak => "client-server-leak",
437 SecurityFindingKind::TaintedSink => "tainted-sink",
438 }
439}
440
441fn record_security_reachability(
442 finding: &SecurityFinding,
443 by_reachability: &mut SecurityReachabilityCounts,
444) {
445 if finding.source_backed {
446 by_reachability.source_backed += 1;
447 }
448 let Some(reachability) = &finding.reachability else {
449 return;
450 };
451
452 if reachability.reachable_from_entry {
453 by_reachability.entry_reachable += 1;
454 }
455 if reachability.reachable_from_untrusted_source {
456 by_reachability.untrusted_source_reachable += 1;
457 }
458 if reachability.crosses_boundary {
459 by_reachability.crosses_boundary += 1;
460 }
461 match reachability.taint_confidence {
462 Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
463 Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
464 None => {}
465 }
466}
467
468fn record_security_runtime_state(
469 finding: &SecurityFinding,
470 by_runtime_state: &mut SecurityRuntimeStateCounts,
471) {
472 match finding.runtime.as_ref().map(|runtime| runtime.state) {
473 Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
474 Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
475 Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
476 Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
477 Some(SecurityRuntimeState::CoverageUnavailable) => {
478 by_runtime_state.coverage_unavailable += 1;
479 }
480 Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
481 None => by_runtime_state.not_collected += 1,
482 }
483}
484
485pub fn serialize_security_json_output<Config, Gate>(
491 output: SecurityOutput<Config, Gate>,
492 analysis_run_id: Option<&str>,
493) -> Result<serde_json::Value, serde_json::Error>
494where
495 Config: Serialize,
496 Gate: Serialize,
497{
498 let mut value = serialize_named_json_output(output, "security")?;
499 attach_telemetry_meta(&mut value, analysis_run_id);
500 Ok(value)
501}
502
503pub fn serialize_security_summary_json_output<Config, Gate>(
509 output: &SecurityOutput<Config, Gate>,
510 analysis_run_id: Option<&str>,
511) -> Result<serde_json::Value, serde_json::Error>
512where
513 Config: Clone + Serialize,
514 Gate: Copy + Serialize,
515{
516 let summary = SecuritySummaryOutput {
517 schema_version: output.schema_version,
518 version: output.version.clone(),
519 elapsed_ms: output.elapsed_ms,
520 config: output.config.clone(),
521 gate_outcomes: output.gate_outcomes.clone(),
522 request_outcomes: output.request_outcomes.clone(),
523 meta: output.meta.clone(),
524 gate: output.gate,
525 workspace_diagnostics: output.workspace_diagnostics.clone(),
526 summary: build_security_summary(output),
527 };
528 let mut value = serialize_named_json_output(summary, "security")?;
529 attach_telemetry_meta(&mut value, analysis_run_id);
530 Ok(value)
531}
532
533pub fn serialize_security_survivors_json_output(
539 output: SecuritySurvivorsOutput,
540) -> Result<serde_json::Value, serde_json::Error> {
541 serialize_named_json_output(output, "security-survivors")
542}
543
544pub fn serialize_security_blind_spots_json_output(
550 output: SecurityBlindSpotsOutput,
551) -> Result<serde_json::Value, serde_json::Error> {
552 serialize_named_json_output(output, "security-blind-spots")
553}
554
555#[derive(Debug, Clone, Deserialize, Serialize)]
557#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
558pub struct SecuritySummary {
559 pub security_findings: usize,
561 pub by_severity: SecuritySeverityCounts,
563 pub by_category: BTreeMap<String, usize>,
566 pub by_reachability: SecurityReachabilityCounts,
568 pub by_runtime_state: SecurityRuntimeStateCounts,
570 pub unresolved_edge_files: usize,
572 pub unresolved_callee_sites: usize,
574 pub attack_surface_entries: usize,
576}
577
578#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
580#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
581pub struct SecuritySeverityCounts {
582 pub high: usize,
584 pub medium: usize,
586 pub low: usize,
588}
589
590#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
592#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
593pub struct SecurityReachabilityCounts {
594 pub entry_reachable: usize,
596 pub untrusted_source_reachable: usize,
598 pub arg_level: usize,
600 pub module_level: usize,
602 pub crosses_boundary: usize,
604 pub source_backed: usize,
606}
607
608#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
610#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
611pub struct SecurityRuntimeStateCounts {
612 pub runtime_hot: usize,
614 pub runtime_cold: usize,
616 pub never_executed: usize,
618 pub low_traffic: usize,
620 pub coverage_unavailable: usize,
622 pub runtime_unknown: usize,
624 pub not_collected: usize,
626}
627
628#[derive(Debug, Clone, Copy, Serialize)]
630#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
631pub enum SecuritySurvivorsSchemaVersion {
632 #[serde(rename = "2")]
634 V2,
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
639#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
640#[serde(rename_all = "kebab-case")]
641pub enum SecurityVerifierVerdictStatus {
642 Survivor,
644 Dismissed,
646 NeedsHumanReview,
648}
649
650#[derive(Debug, Clone, Deserialize, Serialize)]
652#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
653pub struct SecurityVerifierVerdict {
654 pub schema_version: String,
656 pub finding_id: String,
658 pub verdict: SecurityVerifierVerdictStatus,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub reason: Option<String>,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub rationale: Option<String>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub confidence: Option<String>,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub impact: Option<String>,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub fix_direction: Option<String>,
675}
676
677#[derive(Debug, Clone, Serialize)]
679#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
680pub struct SecuritySurvivorsOutput {
681 pub schema_version: SecuritySurvivorsSchemaVersion,
683 pub version: ToolVersion,
685 pub elapsed_ms: ElapsedMs,
687 #[serde(default, skip_serializing_if = "Vec::is_empty")]
689 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
690 pub summary: SecuritySurvivorsSummary,
692 pub survivors: BTreeMap<String, SecuritySurvivor>,
694 pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
697}
698
699#[derive(Debug, Clone, Copy, Serialize)]
701#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
702pub struct SecuritySurvivorsSummary {
703 pub candidates: usize,
705 pub verdicts: usize,
707 pub survivors: usize,
709 pub dismissed: usize,
711 pub needs_human_review: usize,
713 pub unverdicted: usize,
715}
716
717#[derive(Debug, Clone, Serialize)]
719#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
720pub struct SecuritySurvivor {
721 pub finding_id: String,
723 pub verdict: SecurityVerifierVerdictStatus,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub reason: Option<String>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub rationale: Option<String>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub confidence: Option<String>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub impact: Option<String>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub fix_direction: Option<String>,
740 pub candidate: SecurityFinding,
742}
743
744#[derive(Debug, Clone, Copy, Serialize)]
746#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
747pub enum SecurityBlindSpotsSchemaVersion {
748 #[serde(rename = "1")]
750 V1,
751}
752
753#[derive(Debug, Clone, Serialize)]
755#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
756pub struct SecurityBlindSpotsOutput {
757 pub schema_version: SecurityBlindSpotsSchemaVersion,
759 pub version: ToolVersion,
761 pub elapsed_ms: ElapsedMs,
763 #[serde(default, skip_serializing_if = "Vec::is_empty")]
765 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
766 pub summary: SecurityBlindSpotsSummary,
768 pub groups: Vec<SecurityBlindSpotGroup>,
770}
771
772#[derive(Debug, Clone, Copy, Serialize)]
774#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
775pub struct SecurityBlindSpotsSummary {
776 pub unresolved_edge_files: usize,
778 pub unresolved_callee_sites: usize,
780 pub sampled_callee_sites: usize,
782}
783
784#[derive(Debug, Clone, Serialize)]
786#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
787pub struct SecurityBlindSpotGroup {
788 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
790 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
792 pub sampled_count: usize,
794 pub files: Vec<SecurityBlindSpotFile>,
796 pub suggestion: String,
798}
799
800#[derive(Debug, Clone, Serialize)]
802#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
803pub struct SecurityBlindSpotFile {
804 pub path: String,
806 pub sampled_count: usize,
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use serde_json::json;
814
815 fn current_security_envelope() -> serde_json::Value {
816 json!({
817 "schema_version": "8",
818 "version": "test",
819 "elapsed_ms": 1,
820 "config": {
821 "rules": {
822 "security_client_server_leak": {
823 "configured": "warn",
824 "effective": "warn"
825 },
826 "security_sink": {
827 "configured": "warn",
828 "effective": "warn"
829 }
830 },
831 "categories_include": null,
832 "categories_exclude": null
833 },
834 "security_findings": [],
835 "unresolved_edge_files": 0,
836 "unresolved_callee_sites": 0
837 })
838 }
839
840 #[test]
841 fn security_summary_json_output_uses_security_root_contract() {
842 let output = SecurityOutput {
843 gate_outcomes: None,
844 request_outcomes: None,
845 schema_version: SecuritySchemaVersion::V8,
846 version: ToolVersion("test".to_string()),
847 elapsed_ms: ElapsedMs(12),
848 config: json!({"rules": {}}),
849 meta: None,
850 gate: None::<()>,
851 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
852 std::path::Path::new("/project"),
853 std::path::PathBuf::from("package.json"),
854 fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
855 )],
856 security_findings: Vec::new(),
857 attack_surface: None,
858 unresolved_edge_files: 2,
859 unresolved_callee_sites: 3,
860 unresolved_callee_diagnostics: None,
861 };
862
863 let value = serialize_security_summary_json_output(&output, None)
864 .expect("security summary should serialize");
865
866 assert_eq!(value["kind"], "security");
867 assert_eq!(value["schema_version"], "8");
868 assert_eq!(value["summary"]["security_findings"], 0);
869 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
870 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
871 assert_eq!(value["workspace_diagnostics"][0]["path"], "package.json");
872 assert!(value.get("security_findings").is_none());
873 }
874
875 #[test]
876 fn saved_security_validator_accepts_current_full_and_summary_payloads() {
877 let full = current_security_envelope();
878 validate_saved_security_envelope(&full).expect("current full security envelope");
879
880 let mut summary = current_security_envelope();
881 summary
882 .as_object_mut()
883 .expect("security envelope")
884 .remove("security_findings");
885 summary
886 .as_object_mut()
887 .expect("security envelope")
888 .remove("unresolved_edge_files");
889 summary
890 .as_object_mut()
891 .expect("security envelope")
892 .remove("unresolved_callee_sites");
893 summary["summary"] = serde_json::to_value(SecuritySummary {
894 security_findings: 0,
895 by_severity: SecuritySeverityCounts::default(),
896 by_category: BTreeMap::new(),
897 by_reachability: SecurityReachabilityCounts::default(),
898 by_runtime_state: SecurityRuntimeStateCounts::default(),
899 unresolved_edge_files: 0,
900 unresolved_callee_sites: 0,
901 attack_surface_entries: 0,
902 })
903 .expect("security summary");
904 validate_saved_security_envelope(&summary).expect("current summary security envelope");
905 }
906
907 #[test]
908 fn saved_security_validator_accepts_legacy_v7_type_aware_payload() {
909 let mut envelope = current_security_envelope();
910 envelope["schema_version"] = json!("7");
911 envelope["_meta"] = json!({
912 "type_aware": {
913 "executed": true,
914 "protocol_version": 6,
915 "sidecar_version": "0.6.0",
916 "backend": "typescript-go",
917 "backend_version": "7.0.0-dev",
918 "selected_tsconfigs": ["tsconfig.json"],
919 "candidate_count": 1,
920 "confirmed_used_count": 0,
921 "contract_preserved_count": 0,
922 "no_static_references_count": 1,
923 "fix_eligible_count": 0,
924 "unresolved_count": 0,
925 "abstained_count": 0,
926 "abstention_reasons": {
927 "no_project": 0,
928 "ambiguous_project": 0,
929 "blocking_diagnostics": 0,
930 "svelte_virtual_module_exports": 0,
931 "unknown_symbol": 0,
932 "unsupported_syntax": 0,
933 "capacity": 0
934 },
935 "projects": [],
936 "warning_count": 0,
937 "warnings": [],
938 "elapsed_ms": 4,
939 "phase_timings_ms": {
940 "project_setup": 1,
941 "diagnostics": 1,
942 "symbol_scan": 2
943 }
944 }
945 });
946
947 validate_saved_security_envelope(&envelope)
948 .expect("legacy schema 7 type-aware metadata stays readable");
949 }
950
951 #[test]
952 fn saved_security_validator_rejects_malformed_current_payloads() {
953 let mut findings = current_security_envelope();
954 findings["security_findings"] = json!("not-an-array");
955 assert!(validate_saved_security_envelope(&findings).is_err());
956
957 let mut type_aware = current_security_envelope();
958 type_aware["_meta"] = json!({"type_aware": {"queries": "not-an-array"}});
959 assert!(validate_saved_security_envelope(&type_aware).is_err());
960 }
961
962 #[test]
963 fn saved_security_validator_accepts_legacy_and_rejects_future_schema() {
964 validate_saved_security_envelope(&json!({"schema_version": "7"}))
965 .expect("known legacy security schema");
966 let error = validate_saved_security_envelope(&json!({"schema_version": "9"}))
967 .expect_err("future security schema must fail closed");
968 assert!(error.contains("unsupported saved security schema version 9"));
969 }
970
971 fn assert_saved_security_parse_parity<T: DeserializeOwned>(
972 value: &serde_json::Value,
973 label: &str,
974 valid: bool,
975 ) {
976 let owned = serde_json::from_value::<T>(value.clone())
977 .map(|_| ())
978 .map_err(|error| {
979 format!("saved security {label} is incompatible with this Fallow version: {error}")
980 });
981 let borrowed = parse_saved_security::<T>(value, label).map(|_| ());
982 assert_eq!(borrowed.is_ok(), valid, "{label}: {borrowed:?}");
983 assert_eq!(
984 borrowed, owned,
985 "{label} must retain exact validation errors"
986 );
987 }
988
989 #[test]
990 fn saved_security_parsing_preserves_owned_value_contract() {
991 let mut full = current_security_envelope();
992 full["security_findings"] = json!([{
993 "finding_id": "security-fixture",
994 "kind": "tainted-sink",
995 "path": "src/[route]/café.ts",
996 "line": 12,
997 "col": 3,
998 "evidence": "quoted \"value\" and unicode café",
999 "severity": "low",
1000 "trace": [],
1001 "actions": [],
1002 "candidate": fallow_types::results::SecurityCandidate::default()
1003 }]);
1004 full["future_field"] = json!({"nested": ["ignored", {"value": null}]});
1005 assert_saved_security_parse_parity::<SavedSecurityEnvelope>(&full, "envelope", true);
1006 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(&full, "full payload", true);
1007 validate_saved_security_envelope(&full).expect("nonempty current payload remains valid");
1008
1009 for (field, invalid) in [
1010 ("line", json!(-1)),
1011 ("severity", json!("invalid")),
1012 ("candidate", json!([])),
1013 ] {
1014 let mut malformed = full.clone();
1015 malformed["security_findings"][0][field] = invalid;
1016 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
1017 &malformed,
1018 "full payload",
1019 false,
1020 );
1021 assert!(validate_saved_security_envelope(&malformed).is_err());
1022 }
1023 for malformed in [
1024 serde_json::Value::Null,
1025 json!({}),
1026 json!({"security_findings": "not-an-array"}),
1027 ] {
1028 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
1029 &malformed,
1030 "full payload",
1031 false,
1032 );
1033 }
1034 let mut invalid_header = full.clone();
1035 invalid_header["elapsed_ms"] = json!("invalid");
1036 assert_saved_security_parse_parity::<SavedSecurityEnvelope>(
1037 &invalid_header,
1038 "envelope",
1039 false,
1040 );
1041
1042 let summary = json!({"summary": {
1043 "security_findings": 1,
1044 "by_severity": {"high": 0, "medium": 0, "low": 1},
1045 "by_category": {"dangerous-html": 1},
1046 "by_reachability": SecurityReachabilityCounts::default(),
1047 "by_runtime_state": SecurityRuntimeStateCounts::default(),
1048 "unresolved_edge_files": 0,
1049 "unresolved_callee_sites": 0,
1050 "attack_surface_entries": 0
1051 }});
1052 assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
1053 &summary,
1054 "summary payload",
1055 true,
1056 );
1057 assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
1058 &json!({"summary": false}),
1059 "summary payload",
1060 false,
1061 );
1062 }
1063}