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(default, skip_serializing_if = "Option::is_none")]
136 pub gate_outcomes: Option<crate::GateOutcomes>,
137 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
139 pub meta: Option<Meta>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub gate: Option<Gate>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
148 pub security_findings: Vec<SecurityFinding>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
154 pub unresolved_edge_files: usize,
159 pub unresolved_callee_sites: usize,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
167}
168
169#[derive(Debug, Clone, Deserialize, Serialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172pub struct SecurityUnresolvedCalleeDiagnostics {
173 pub sampled: Vec<SecurityUnresolvedCalleeSample>,
175 pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
177 pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
179 pub sample_limit: usize,
181 pub top_files_limit: usize,
183}
184
185#[derive(Debug, Clone, Deserialize, Serialize)]
187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
188pub struct SecurityUnresolvedCalleeSample {
189 pub path: String,
191 pub line: u32,
193 pub col: u32,
195 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
197 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct SecurityUnresolvedCalleeTopFile {
205 pub path: String,
207 pub count: usize,
209}
210
211#[derive(Debug, Clone, Deserialize, Serialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214pub struct SecurityUnresolvedCalleeReasonCount {
215 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
217 pub count: usize,
219}
220
221#[derive(Debug, Clone, Serialize)]
225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
226pub struct SecuritySummaryOutput<Config, Gate> {
227 pub schema_version: SecuritySchemaVersion,
229 pub version: ToolVersion,
231 pub elapsed_ms: ElapsedMs,
233 pub config: Config,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub gate_outcomes: Option<crate::GateOutcomes>,
244 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
246 pub meta: Option<Meta>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub gate: Option<Gate>,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
253 pub summary: SecuritySummary,
255}
256
257#[derive(Deserialize)]
258#[serde(rename_all = "kebab-case")]
259enum SavedSecurityGateMode {
260 New,
261 NewlyReachable,
262}
263
264#[derive(Deserialize)]
265struct SavedSecurityEnvelope {
266 #[serde(rename = "version")]
267 _version: String,
268 #[serde(rename = "elapsed_ms")]
269 _elapsed_ms: u64,
270 #[serde(rename = "config")]
271 _config: SecurityOutputConfig<fallow_config::Severity>,
272 #[serde(rename = "_meta", default)]
273 meta: Option<serde_json::Value>,
274 #[serde(rename = "gate", default)]
275 _gate: Option<SecurityGate<SavedSecurityGateMode>>,
276}
277
278#[derive(Deserialize)]
279struct SavedSecurityFullPayload {
280 #[serde(rename = "security_findings")]
281 _security_findings: Vec<SecurityFinding>,
282 #[serde(rename = "attack_surface", default)]
283 _attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
284 #[serde(rename = "unresolved_edge_files")]
285 _unresolved_edge_files: usize,
286 #[serde(rename = "unresolved_callee_sites")]
287 _unresolved_callee_sites: usize,
288 #[serde(rename = "unresolved_callee_diagnostics", default)]
289 _unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
290}
291
292#[derive(Deserialize)]
293struct SavedSecuritySummaryPayload {
294 #[serde(rename = "summary")]
295 _summary: SecuritySummary,
296}
297
298pub fn validate_saved_security_envelope(value: &serde_json::Value) -> Result<(), String> {
303 let raw_version = value
304 .get("schema_version")
305 .and_then(serde_json::Value::as_str)
306 .ok_or_else(|| "saved security envelope is missing a string `schema_version`".to_owned())?;
307 let version = raw_version.parse::<u32>().map_err(|_| {
308 format!("saved security envelope has invalid schema version `{raw_version}`")
309 })?;
310 let current = SECURITY_SCHEMA_VERSION;
311 if version == 0 || version > current {
312 return Err(format!(
313 "unsupported saved security schema version {version}; this Fallow version supports versions 1 through {current}"
314 ));
315 }
316 if version < current {
317 return Ok(());
318 }
319
320 let envelope: SavedSecurityEnvelope = parse_saved_security(value, "envelope")?;
321 if let Some(meta) = envelope.meta {
322 let meta = meta
323 .as_object()
324 .ok_or_else(|| "saved security envelope field `_meta` must be an object".to_owned())?;
325 if let Some(type_aware) = meta.get("type_aware").filter(|value| !value.is_null()) {
326 parse_saved_security::<fallow_types::envelope::TypeAwareMeta>(
327 type_aware,
328 "type-aware metadata",
329 )?;
330 }
331 }
332
333 if value.get("security_findings").is_some() {
334 parse_saved_security::<SavedSecurityFullPayload>(value, "full payload")?;
335 } else if value.get("summary").is_some() {
336 parse_saved_security::<SavedSecuritySummaryPayload>(value, "summary payload")?;
337 } else {
338 return Err(
339 "saved security envelope is missing `security_findings` or `summary`".to_owned(),
340 );
341 }
342 Ok(())
343}
344
345fn parse_saved_security<T: DeserializeOwned>(
346 value: &serde_json::Value,
347 label: &str,
348) -> Result<T, String> {
349 T::deserialize(value).map_err(|error| {
350 format!("saved security {label} is incompatible with this Fallow version: {error}")
351 })
352}
353
354#[must_use]
356pub fn build_security_summary<Config, Gate>(
357 output: &SecurityOutput<Config, Gate>,
358) -> SecuritySummary {
359 let mut counts = SecuritySummaryCounts::default();
360
361 for finding in &output.security_findings {
362 counts.record(finding);
363 }
364
365 SecuritySummary {
366 security_findings: output.security_findings.len(),
367 by_severity: counts.severity,
368 by_category: counts.category,
369 by_reachability: counts.reachability,
370 by_runtime_state: counts.runtime_state,
371 unresolved_edge_files: output.unresolved_edge_files,
372 unresolved_callee_sites: output.unresolved_callee_sites,
373 attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
374 }
375}
376
377#[derive(Default)]
378struct SecuritySummaryCounts {
379 severity: SecuritySeverityCounts,
380 category: BTreeMap<String, usize>,
381 reachability: SecurityReachabilityCounts,
382 runtime_state: SecurityRuntimeStateCounts,
383}
384
385impl SecuritySummaryCounts {
386 fn record(&mut self, finding: &SecurityFinding) {
387 record_security_severity(finding.severity, &mut self.severity);
388 record_security_category(finding, &mut self.category);
389 record_security_reachability(finding, &mut self.reachability);
390 record_security_runtime_state(finding, &mut self.runtime_state);
391 }
392}
393
394fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
395 match severity {
396 SecuritySeverity::High => by_severity.high += 1,
397 SecuritySeverity::Medium => by_severity.medium += 1,
398 SecuritySeverity::Low => by_severity.low += 1,
399 }
400}
401
402fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
403 let category = finding
404 .category
405 .clone()
406 .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
407 *by_category.entry(category).or_insert(0) += 1;
408}
409
410fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
411 match kind {
412 SecurityFindingKind::ClientServerLeak => "client-server-leak",
413 SecurityFindingKind::TaintedSink => "tainted-sink",
414 }
415}
416
417fn record_security_reachability(
418 finding: &SecurityFinding,
419 by_reachability: &mut SecurityReachabilityCounts,
420) {
421 if finding.source_backed {
422 by_reachability.source_backed += 1;
423 }
424 let Some(reachability) = &finding.reachability else {
425 return;
426 };
427
428 if reachability.reachable_from_entry {
429 by_reachability.entry_reachable += 1;
430 }
431 if reachability.reachable_from_untrusted_source {
432 by_reachability.untrusted_source_reachable += 1;
433 }
434 if reachability.crosses_boundary {
435 by_reachability.crosses_boundary += 1;
436 }
437 match reachability.taint_confidence {
438 Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
439 Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
440 None => {}
441 }
442}
443
444fn record_security_runtime_state(
445 finding: &SecurityFinding,
446 by_runtime_state: &mut SecurityRuntimeStateCounts,
447) {
448 match finding.runtime.as_ref().map(|runtime| runtime.state) {
449 Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
450 Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
451 Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
452 Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
453 Some(SecurityRuntimeState::CoverageUnavailable) => {
454 by_runtime_state.coverage_unavailable += 1;
455 }
456 Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
457 None => by_runtime_state.not_collected += 1,
458 }
459}
460
461pub fn serialize_security_json_output<Config, Gate>(
467 output: SecurityOutput<Config, Gate>,
468 mode: RootEnvelopeMode,
469 analysis_run_id: Option<&str>,
470) -> Result<serde_json::Value, serde_json::Error>
471where
472 Config: Serialize,
473 Gate: Serialize,
474{
475 let mut value = serialize_named_json_output(output, "security", mode)?;
476 attach_telemetry_meta(&mut value, analysis_run_id);
477 Ok(value)
478}
479
480pub fn serialize_security_summary_json_output<Config, Gate>(
486 output: &SecurityOutput<Config, Gate>,
487 mode: RootEnvelopeMode,
488 analysis_run_id: Option<&str>,
489) -> Result<serde_json::Value, serde_json::Error>
490where
491 Config: Clone + Serialize,
492 Gate: Copy + Serialize,
493{
494 let summary = SecuritySummaryOutput {
495 schema_version: output.schema_version,
496 version: output.version.clone(),
497 elapsed_ms: output.elapsed_ms,
498 config: output.config.clone(),
499 gate_outcomes: output.gate_outcomes.clone(),
500 meta: output.meta.clone(),
501 gate: output.gate,
502 workspace_diagnostics: output.workspace_diagnostics.clone(),
503 summary: build_security_summary(output),
504 };
505 let mut value = serialize_named_json_output(summary, "security", mode)?;
506 attach_telemetry_meta(&mut value, analysis_run_id);
507 Ok(value)
508}
509
510pub fn serialize_security_survivors_json_output(
516 output: SecuritySurvivorsOutput,
517 mode: RootEnvelopeMode,
518) -> Result<serde_json::Value, serde_json::Error> {
519 serialize_named_json_output(output, "security-survivors", mode)
520}
521
522pub fn serialize_security_blind_spots_json_output(
528 output: SecurityBlindSpotsOutput,
529 mode: RootEnvelopeMode,
530) -> Result<serde_json::Value, serde_json::Error> {
531 serialize_named_json_output(output, "security-blind-spots", mode)
532}
533
534#[derive(Debug, Clone, Deserialize, Serialize)]
536#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
537pub struct SecuritySummary {
538 pub security_findings: usize,
540 pub by_severity: SecuritySeverityCounts,
542 pub by_category: BTreeMap<String, usize>,
545 pub by_reachability: SecurityReachabilityCounts,
547 pub by_runtime_state: SecurityRuntimeStateCounts,
549 pub unresolved_edge_files: usize,
551 pub unresolved_callee_sites: usize,
553 pub attack_surface_entries: usize,
555}
556
557#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
559#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
560pub struct SecuritySeverityCounts {
561 pub high: usize,
563 pub medium: usize,
565 pub low: usize,
567}
568
569#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
571#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
572pub struct SecurityReachabilityCounts {
573 pub entry_reachable: usize,
575 pub untrusted_source_reachable: usize,
577 pub arg_level: usize,
579 pub module_level: usize,
581 pub crosses_boundary: usize,
583 pub source_backed: usize,
585}
586
587#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
589#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
590pub struct SecurityRuntimeStateCounts {
591 pub runtime_hot: usize,
593 pub runtime_cold: usize,
595 pub never_executed: usize,
597 pub low_traffic: usize,
599 pub coverage_unavailable: usize,
601 pub runtime_unknown: usize,
603 pub not_collected: usize,
605}
606
607#[derive(Debug, Clone, Copy, Serialize)]
609#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
610pub enum SecuritySurvivorsSchemaVersion {
611 #[serde(rename = "2")]
613 V2,
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
618#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
619#[serde(rename_all = "kebab-case")]
620pub enum SecurityVerifierVerdictStatus {
621 Survivor,
623 Dismissed,
625 NeedsHumanReview,
627}
628
629#[derive(Debug, Clone, Deserialize, Serialize)]
631#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
632pub struct SecurityVerifierVerdict {
633 pub schema_version: String,
635 pub finding_id: String,
637 pub verdict: SecurityVerifierVerdictStatus,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub reason: Option<String>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub rationale: Option<String>,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub confidence: Option<String>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub impact: Option<String>,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub fix_direction: Option<String>,
654}
655
656#[derive(Debug, Clone, Serialize)]
658#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
659pub struct SecuritySurvivorsOutput {
660 pub schema_version: SecuritySurvivorsSchemaVersion,
662 pub version: ToolVersion,
664 pub elapsed_ms: ElapsedMs,
666 #[serde(default, skip_serializing_if = "Vec::is_empty")]
668 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
669 pub summary: SecuritySurvivorsSummary,
671 pub survivors: BTreeMap<String, SecuritySurvivor>,
673 pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
676}
677
678#[derive(Debug, Clone, Copy, Serialize)]
680#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
681pub struct SecuritySurvivorsSummary {
682 pub candidates: usize,
684 pub verdicts: usize,
686 pub survivors: usize,
688 pub dismissed: usize,
690 pub needs_human_review: usize,
692 pub unverdicted: usize,
694}
695
696#[derive(Debug, Clone, Serialize)]
698#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
699pub struct SecuritySurvivor {
700 pub finding_id: String,
702 pub verdict: SecurityVerifierVerdictStatus,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub reason: Option<String>,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub rationale: Option<String>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub confidence: Option<String>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub impact: Option<String>,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub fix_direction: Option<String>,
719 pub candidate: SecurityFinding,
721}
722
723#[derive(Debug, Clone, Copy, Serialize)]
725#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
726pub enum SecurityBlindSpotsSchemaVersion {
727 #[serde(rename = "1")]
729 V1,
730}
731
732#[derive(Debug, Clone, Serialize)]
734#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
735pub struct SecurityBlindSpotsOutput {
736 pub schema_version: SecurityBlindSpotsSchemaVersion,
738 pub version: ToolVersion,
740 pub elapsed_ms: ElapsedMs,
742 #[serde(default, skip_serializing_if = "Vec::is_empty")]
744 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
745 pub summary: SecurityBlindSpotsSummary,
747 pub groups: Vec<SecurityBlindSpotGroup>,
749}
750
751#[derive(Debug, Clone, Copy, Serialize)]
753#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
754pub struct SecurityBlindSpotsSummary {
755 pub unresolved_edge_files: usize,
757 pub unresolved_callee_sites: usize,
759 pub sampled_callee_sites: usize,
761}
762
763#[derive(Debug, Clone, Serialize)]
765#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
766pub struct SecurityBlindSpotGroup {
767 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
769 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
771 pub sampled_count: usize,
773 pub files: Vec<SecurityBlindSpotFile>,
775 pub suggestion: String,
777}
778
779#[derive(Debug, Clone, Serialize)]
781#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
782pub struct SecurityBlindSpotFile {
783 pub path: String,
785 pub sampled_count: usize,
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use serde_json::json;
793
794 fn current_security_envelope() -> serde_json::Value {
795 json!({
796 "schema_version": "8",
797 "version": "test",
798 "elapsed_ms": 1,
799 "config": {
800 "rules": {
801 "security_client_server_leak": {
802 "configured": "warn",
803 "effective": "warn"
804 },
805 "security_sink": {
806 "configured": "warn",
807 "effective": "warn"
808 }
809 },
810 "categories_include": null,
811 "categories_exclude": null
812 },
813 "security_findings": [],
814 "unresolved_edge_files": 0,
815 "unresolved_callee_sites": 0
816 })
817 }
818
819 #[test]
820 fn security_summary_json_output_uses_security_root_contract() {
821 let output = SecurityOutput {
822 gate_outcomes: None,
823 schema_version: SecuritySchemaVersion::V8,
824 version: ToolVersion("test".to_string()),
825 elapsed_ms: ElapsedMs(12),
826 config: json!({"rules": {}}),
827 meta: None,
828 gate: None::<()>,
829 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
830 std::path::Path::new("/project"),
831 std::path::PathBuf::from("package.json"),
832 fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
833 )],
834 security_findings: Vec::new(),
835 attack_surface: None,
836 unresolved_edge_files: 2,
837 unresolved_callee_sites: 3,
838 unresolved_callee_diagnostics: None,
839 };
840
841 let value = serialize_security_summary_json_output(&output, RootEnvelopeMode::Tagged, None)
842 .expect("security summary should serialize");
843
844 assert_eq!(value["kind"], "security");
845 assert_eq!(value["schema_version"], "8");
846 assert_eq!(value["summary"]["security_findings"], 0);
847 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
848 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
849 assert_eq!(value["workspace_diagnostics"][0]["path"], "package.json");
850 assert!(value.get("security_findings").is_none());
851 }
852
853 #[test]
854 fn saved_security_validator_accepts_current_full_and_summary_payloads() {
855 let full = current_security_envelope();
856 validate_saved_security_envelope(&full).expect("current full security envelope");
857
858 let mut summary = current_security_envelope();
859 summary
860 .as_object_mut()
861 .expect("security envelope")
862 .remove("security_findings");
863 summary
864 .as_object_mut()
865 .expect("security envelope")
866 .remove("unresolved_edge_files");
867 summary
868 .as_object_mut()
869 .expect("security envelope")
870 .remove("unresolved_callee_sites");
871 summary["summary"] = serde_json::to_value(SecuritySummary {
872 security_findings: 0,
873 by_severity: SecuritySeverityCounts::default(),
874 by_category: BTreeMap::new(),
875 by_reachability: SecurityReachabilityCounts::default(),
876 by_runtime_state: SecurityRuntimeStateCounts::default(),
877 unresolved_edge_files: 0,
878 unresolved_callee_sites: 0,
879 attack_surface_entries: 0,
880 })
881 .expect("security summary");
882 validate_saved_security_envelope(&summary).expect("current summary security envelope");
883 }
884
885 #[test]
886 fn saved_security_validator_accepts_legacy_v7_type_aware_payload() {
887 let mut envelope = current_security_envelope();
888 envelope["schema_version"] = json!("7");
889 envelope["_meta"] = json!({
890 "type_aware": {
891 "executed": true,
892 "protocol_version": 6,
893 "sidecar_version": "0.6.0",
894 "backend": "typescript-go",
895 "backend_version": "7.0.0-dev",
896 "selected_tsconfigs": ["tsconfig.json"],
897 "candidate_count": 1,
898 "confirmed_used_count": 0,
899 "contract_preserved_count": 0,
900 "no_static_references_count": 1,
901 "fix_eligible_count": 0,
902 "unresolved_count": 0,
903 "abstained_count": 0,
904 "abstention_reasons": {
905 "no_project": 0,
906 "ambiguous_project": 0,
907 "blocking_diagnostics": 0,
908 "svelte_virtual_module_exports": 0,
909 "unknown_symbol": 0,
910 "unsupported_syntax": 0,
911 "capacity": 0
912 },
913 "projects": [],
914 "warning_count": 0,
915 "warnings": [],
916 "elapsed_ms": 4,
917 "phase_timings_ms": {
918 "project_setup": 1,
919 "diagnostics": 1,
920 "symbol_scan": 2
921 }
922 }
923 });
924
925 validate_saved_security_envelope(&envelope)
926 .expect("legacy schema 7 type-aware metadata stays readable");
927 }
928
929 #[test]
930 fn saved_security_validator_rejects_malformed_current_payloads() {
931 let mut findings = current_security_envelope();
932 findings["security_findings"] = json!("not-an-array");
933 assert!(validate_saved_security_envelope(&findings).is_err());
934
935 let mut type_aware = current_security_envelope();
936 type_aware["_meta"] = json!({"type_aware": {"queries": "not-an-array"}});
937 assert!(validate_saved_security_envelope(&type_aware).is_err());
938 }
939
940 #[test]
941 fn saved_security_validator_accepts_legacy_and_rejects_future_schema() {
942 validate_saved_security_envelope(&json!({"schema_version": "7"}))
943 .expect("known legacy security schema");
944 let error = validate_saved_security_envelope(&json!({"schema_version": "9"}))
945 .expect_err("future security schema must fail closed");
946 assert!(error.contains("unsupported saved security schema version 9"));
947 }
948
949 fn assert_saved_security_parse_parity<T: DeserializeOwned>(
950 value: &serde_json::Value,
951 label: &str,
952 valid: bool,
953 ) {
954 let owned = serde_json::from_value::<T>(value.clone())
955 .map(|_| ())
956 .map_err(|error| {
957 format!("saved security {label} is incompatible with this Fallow version: {error}")
958 });
959 let borrowed = parse_saved_security::<T>(value, label).map(|_| ());
960 assert_eq!(borrowed.is_ok(), valid, "{label}: {borrowed:?}");
961 assert_eq!(
962 borrowed, owned,
963 "{label} must retain exact validation errors"
964 );
965 }
966
967 #[test]
968 fn saved_security_parsing_preserves_owned_value_contract() {
969 let mut full = current_security_envelope();
970 full["security_findings"] = json!([{
971 "finding_id": "security-fixture",
972 "kind": "tainted-sink",
973 "path": "src/[route]/café.ts",
974 "line": 12,
975 "col": 3,
976 "evidence": "quoted \"value\" and unicode café",
977 "severity": "low",
978 "trace": [],
979 "actions": [],
980 "candidate": fallow_types::results::SecurityCandidate::default()
981 }]);
982 full["future_field"] = json!({"nested": ["ignored", {"value": null}]});
983 assert_saved_security_parse_parity::<SavedSecurityEnvelope>(&full, "envelope", true);
984 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(&full, "full payload", true);
985 validate_saved_security_envelope(&full).expect("nonempty current payload remains valid");
986
987 for (field, invalid) in [
988 ("line", json!(-1)),
989 ("severity", json!("invalid")),
990 ("candidate", json!([])),
991 ] {
992 let mut malformed = full.clone();
993 malformed["security_findings"][0][field] = invalid;
994 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
995 &malformed,
996 "full payload",
997 false,
998 );
999 assert!(validate_saved_security_envelope(&malformed).is_err());
1000 }
1001 for malformed in [
1002 serde_json::Value::Null,
1003 json!({}),
1004 json!({"security_findings": "not-an-array"}),
1005 ] {
1006 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
1007 &malformed,
1008 "full payload",
1009 false,
1010 );
1011 }
1012 let mut invalid_header = full.clone();
1013 invalid_header["elapsed_ms"] = json!("invalid");
1014 assert_saved_security_parse_parity::<SavedSecurityEnvelope>(
1015 &invalid_header,
1016 "envelope",
1017 false,
1018 );
1019
1020 let summary = json!({"summary": {
1021 "security_findings": 1,
1022 "by_severity": {"high": 0, "medium": 0, "low": 1},
1023 "by_category": {"dangerous-html": 1},
1024 "by_reachability": SecurityReachabilityCounts::default(),
1025 "by_runtime_state": SecurityRuntimeStateCounts::default(),
1026 "unresolved_edge_files": 0,
1027 "unresolved_callee_sites": 0,
1028 "attack_surface_entries": 0
1029 }});
1030 assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
1031 &summary,
1032 "summary payload",
1033 true,
1034 );
1035 assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
1036 &json!({"summary": false}),
1037 "summary payload",
1038 false,
1039 );
1040 }
1041}