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