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(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 mode: RootEnvelopeMode,
487 analysis_run_id: Option<&str>,
488) -> Result<serde_json::Value, serde_json::Error>
489where
490 Config: Serialize,
491 Gate: Serialize,
492{
493 let mut value = serialize_named_json_output(output, "security", mode)?;
494 attach_telemetry_meta(&mut value, analysis_run_id);
495 Ok(value)
496}
497
498pub fn serialize_security_summary_json_output<Config, Gate>(
504 output: &SecurityOutput<Config, Gate>,
505 mode: RootEnvelopeMode,
506 analysis_run_id: Option<&str>,
507) -> Result<serde_json::Value, serde_json::Error>
508where
509 Config: Clone + Serialize,
510 Gate: Copy + Serialize,
511{
512 let summary = SecuritySummaryOutput {
513 schema_version: output.schema_version,
514 version: output.version.clone(),
515 elapsed_ms: output.elapsed_ms,
516 config: output.config.clone(),
517 gate_outcomes: output.gate_outcomes.clone(),
518 request_outcomes: output.request_outcomes.clone(),
519 meta: output.meta.clone(),
520 gate: output.gate,
521 workspace_diagnostics: output.workspace_diagnostics.clone(),
522 summary: build_security_summary(output),
523 };
524 let mut value = serialize_named_json_output(summary, "security", mode)?;
525 attach_telemetry_meta(&mut value, analysis_run_id);
526 Ok(value)
527}
528
529pub fn serialize_security_survivors_json_output(
535 output: SecuritySurvivorsOutput,
536 mode: RootEnvelopeMode,
537) -> Result<serde_json::Value, serde_json::Error> {
538 serialize_named_json_output(output, "security-survivors", mode)
539}
540
541pub fn serialize_security_blind_spots_json_output(
547 output: SecurityBlindSpotsOutput,
548 mode: RootEnvelopeMode,
549) -> Result<serde_json::Value, serde_json::Error> {
550 serialize_named_json_output(output, "security-blind-spots", mode)
551}
552
553#[derive(Debug, Clone, Deserialize, Serialize)]
555#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
556pub struct SecuritySummary {
557 pub security_findings: usize,
559 pub by_severity: SecuritySeverityCounts,
561 pub by_category: BTreeMap<String, usize>,
564 pub by_reachability: SecurityReachabilityCounts,
566 pub by_runtime_state: SecurityRuntimeStateCounts,
568 pub unresolved_edge_files: usize,
570 pub unresolved_callee_sites: usize,
572 pub attack_surface_entries: usize,
574}
575
576#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
578#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
579pub struct SecuritySeverityCounts {
580 pub high: usize,
582 pub medium: usize,
584 pub low: usize,
586}
587
588#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591pub struct SecurityReachabilityCounts {
592 pub entry_reachable: usize,
594 pub untrusted_source_reachable: usize,
596 pub arg_level: usize,
598 pub module_level: usize,
600 pub crosses_boundary: usize,
602 pub source_backed: usize,
604}
605
606#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
608#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
609pub struct SecurityRuntimeStateCounts {
610 pub runtime_hot: usize,
612 pub runtime_cold: usize,
614 pub never_executed: usize,
616 pub low_traffic: usize,
618 pub coverage_unavailable: usize,
620 pub runtime_unknown: usize,
622 pub not_collected: usize,
624}
625
626#[derive(Debug, Clone, Copy, Serialize)]
628#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
629pub enum SecuritySurvivorsSchemaVersion {
630 #[serde(rename = "2")]
632 V2,
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
638#[serde(rename_all = "kebab-case")]
639pub enum SecurityVerifierVerdictStatus {
640 Survivor,
642 Dismissed,
644 NeedsHumanReview,
646}
647
648#[derive(Debug, Clone, Deserialize, Serialize)]
650#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
651pub struct SecurityVerifierVerdict {
652 pub schema_version: String,
654 pub finding_id: String,
656 pub verdict: SecurityVerifierVerdictStatus,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub reason: Option<String>,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub rationale: Option<String>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub confidence: Option<String>,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub impact: Option<String>,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub fix_direction: Option<String>,
673}
674
675#[derive(Debug, Clone, Serialize)]
677#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
678pub struct SecuritySurvivorsOutput {
679 pub schema_version: SecuritySurvivorsSchemaVersion,
681 pub version: ToolVersion,
683 pub elapsed_ms: ElapsedMs,
685 #[serde(default, skip_serializing_if = "Vec::is_empty")]
687 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
688 pub summary: SecuritySurvivorsSummary,
690 pub survivors: BTreeMap<String, SecuritySurvivor>,
692 pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
695}
696
697#[derive(Debug, Clone, Copy, Serialize)]
699#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
700pub struct SecuritySurvivorsSummary {
701 pub candidates: usize,
703 pub verdicts: usize,
705 pub survivors: usize,
707 pub dismissed: usize,
709 pub needs_human_review: usize,
711 pub unverdicted: usize,
713}
714
715#[derive(Debug, Clone, Serialize)]
717#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
718pub struct SecuritySurvivor {
719 pub finding_id: String,
721 pub verdict: SecurityVerifierVerdictStatus,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub reason: Option<String>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub rationale: Option<String>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub confidence: Option<String>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub impact: Option<String>,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub fix_direction: Option<String>,
738 pub candidate: SecurityFinding,
740}
741
742#[derive(Debug, Clone, Copy, Serialize)]
744#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
745pub enum SecurityBlindSpotsSchemaVersion {
746 #[serde(rename = "1")]
748 V1,
749}
750
751#[derive(Debug, Clone, Serialize)]
753#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
754pub struct SecurityBlindSpotsOutput {
755 pub schema_version: SecurityBlindSpotsSchemaVersion,
757 pub version: ToolVersion,
759 pub elapsed_ms: ElapsedMs,
761 #[serde(default, skip_serializing_if = "Vec::is_empty")]
763 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
764 pub summary: SecurityBlindSpotsSummary,
766 pub groups: Vec<SecurityBlindSpotGroup>,
768}
769
770#[derive(Debug, Clone, Copy, Serialize)]
772#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
773pub struct SecurityBlindSpotsSummary {
774 pub unresolved_edge_files: usize,
776 pub unresolved_callee_sites: usize,
778 pub sampled_callee_sites: usize,
780}
781
782#[derive(Debug, Clone, Serialize)]
784#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
785pub struct SecurityBlindSpotGroup {
786 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
788 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
790 pub sampled_count: usize,
792 pub files: Vec<SecurityBlindSpotFile>,
794 pub suggestion: String,
796}
797
798#[derive(Debug, Clone, Serialize)]
800#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
801pub struct SecurityBlindSpotFile {
802 pub path: String,
804 pub sampled_count: usize,
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811 use serde_json::json;
812
813 fn current_security_envelope() -> serde_json::Value {
814 json!({
815 "schema_version": "8",
816 "version": "test",
817 "elapsed_ms": 1,
818 "config": {
819 "rules": {
820 "security_client_server_leak": {
821 "configured": "warn",
822 "effective": "warn"
823 },
824 "security_sink": {
825 "configured": "warn",
826 "effective": "warn"
827 }
828 },
829 "categories_include": null,
830 "categories_exclude": null
831 },
832 "security_findings": [],
833 "unresolved_edge_files": 0,
834 "unresolved_callee_sites": 0
835 })
836 }
837
838 #[test]
839 fn security_summary_json_output_uses_security_root_contract() {
840 let output = SecurityOutput {
841 gate_outcomes: None,
842 request_outcomes: None,
843 schema_version: SecuritySchemaVersion::V8,
844 version: ToolVersion("test".to_string()),
845 elapsed_ms: ElapsedMs(12),
846 config: json!({"rules": {}}),
847 meta: None,
848 gate: None::<()>,
849 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
850 std::path::Path::new("/project"),
851 std::path::PathBuf::from("package.json"),
852 fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
853 )],
854 security_findings: Vec::new(),
855 attack_surface: None,
856 unresolved_edge_files: 2,
857 unresolved_callee_sites: 3,
858 unresolved_callee_diagnostics: None,
859 };
860
861 let value = serialize_security_summary_json_output(&output, RootEnvelopeMode::Tagged, None)
862 .expect("security summary should serialize");
863
864 assert_eq!(value["kind"], "security");
865 assert_eq!(value["schema_version"], "8");
866 assert_eq!(value["summary"]["security_findings"], 0);
867 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
868 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
869 assert_eq!(value["workspace_diagnostics"][0]["path"], "package.json");
870 assert!(value.get("security_findings").is_none());
871 }
872
873 #[test]
874 fn saved_security_validator_accepts_current_full_and_summary_payloads() {
875 let full = current_security_envelope();
876 validate_saved_security_envelope(&full).expect("current full security envelope");
877
878 let mut summary = current_security_envelope();
879 summary
880 .as_object_mut()
881 .expect("security envelope")
882 .remove("security_findings");
883 summary
884 .as_object_mut()
885 .expect("security envelope")
886 .remove("unresolved_edge_files");
887 summary
888 .as_object_mut()
889 .expect("security envelope")
890 .remove("unresolved_callee_sites");
891 summary["summary"] = serde_json::to_value(SecuritySummary {
892 security_findings: 0,
893 by_severity: SecuritySeverityCounts::default(),
894 by_category: BTreeMap::new(),
895 by_reachability: SecurityReachabilityCounts::default(),
896 by_runtime_state: SecurityRuntimeStateCounts::default(),
897 unresolved_edge_files: 0,
898 unresolved_callee_sites: 0,
899 attack_surface_entries: 0,
900 })
901 .expect("security summary");
902 validate_saved_security_envelope(&summary).expect("current summary security envelope");
903 }
904
905 #[test]
906 fn saved_security_validator_accepts_legacy_v7_type_aware_payload() {
907 let mut envelope = current_security_envelope();
908 envelope["schema_version"] = json!("7");
909 envelope["_meta"] = json!({
910 "type_aware": {
911 "executed": true,
912 "protocol_version": 6,
913 "sidecar_version": "0.6.0",
914 "backend": "typescript-go",
915 "backend_version": "7.0.0-dev",
916 "selected_tsconfigs": ["tsconfig.json"],
917 "candidate_count": 1,
918 "confirmed_used_count": 0,
919 "contract_preserved_count": 0,
920 "no_static_references_count": 1,
921 "fix_eligible_count": 0,
922 "unresolved_count": 0,
923 "abstained_count": 0,
924 "abstention_reasons": {
925 "no_project": 0,
926 "ambiguous_project": 0,
927 "blocking_diagnostics": 0,
928 "svelte_virtual_module_exports": 0,
929 "unknown_symbol": 0,
930 "unsupported_syntax": 0,
931 "capacity": 0
932 },
933 "projects": [],
934 "warning_count": 0,
935 "warnings": [],
936 "elapsed_ms": 4,
937 "phase_timings_ms": {
938 "project_setup": 1,
939 "diagnostics": 1,
940 "symbol_scan": 2
941 }
942 }
943 });
944
945 validate_saved_security_envelope(&envelope)
946 .expect("legacy schema 7 type-aware metadata stays readable");
947 }
948
949 #[test]
950 fn saved_security_validator_rejects_malformed_current_payloads() {
951 let mut findings = current_security_envelope();
952 findings["security_findings"] = json!("not-an-array");
953 assert!(validate_saved_security_envelope(&findings).is_err());
954
955 let mut type_aware = current_security_envelope();
956 type_aware["_meta"] = json!({"type_aware": {"queries": "not-an-array"}});
957 assert!(validate_saved_security_envelope(&type_aware).is_err());
958 }
959
960 #[test]
961 fn saved_security_validator_accepts_legacy_and_rejects_future_schema() {
962 validate_saved_security_envelope(&json!({"schema_version": "7"}))
963 .expect("known legacy security schema");
964 let error = validate_saved_security_envelope(&json!({"schema_version": "9"}))
965 .expect_err("future security schema must fail closed");
966 assert!(error.contains("unsupported saved security schema version 9"));
967 }
968
969 fn assert_saved_security_parse_parity<T: DeserializeOwned>(
970 value: &serde_json::Value,
971 label: &str,
972 valid: bool,
973 ) {
974 let owned = serde_json::from_value::<T>(value.clone())
975 .map(|_| ())
976 .map_err(|error| {
977 format!("saved security {label} is incompatible with this Fallow version: {error}")
978 });
979 let borrowed = parse_saved_security::<T>(value, label).map(|_| ());
980 assert_eq!(borrowed.is_ok(), valid, "{label}: {borrowed:?}");
981 assert_eq!(
982 borrowed, owned,
983 "{label} must retain exact validation errors"
984 );
985 }
986
987 #[test]
988 fn saved_security_parsing_preserves_owned_value_contract() {
989 let mut full = current_security_envelope();
990 full["security_findings"] = json!([{
991 "finding_id": "security-fixture",
992 "kind": "tainted-sink",
993 "path": "src/[route]/café.ts",
994 "line": 12,
995 "col": 3,
996 "evidence": "quoted \"value\" and unicode café",
997 "severity": "low",
998 "trace": [],
999 "actions": [],
1000 "candidate": fallow_types::results::SecurityCandidate::default()
1001 }]);
1002 full["future_field"] = json!({"nested": ["ignored", {"value": null}]});
1003 assert_saved_security_parse_parity::<SavedSecurityEnvelope>(&full, "envelope", true);
1004 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(&full, "full payload", true);
1005 validate_saved_security_envelope(&full).expect("nonempty current payload remains valid");
1006
1007 for (field, invalid) in [
1008 ("line", json!(-1)),
1009 ("severity", json!("invalid")),
1010 ("candidate", json!([])),
1011 ] {
1012 let mut malformed = full.clone();
1013 malformed["security_findings"][0][field] = invalid;
1014 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
1015 &malformed,
1016 "full payload",
1017 false,
1018 );
1019 assert!(validate_saved_security_envelope(&malformed).is_err());
1020 }
1021 for malformed in [
1022 serde_json::Value::Null,
1023 json!({}),
1024 json!({"security_findings": "not-an-array"}),
1025 ] {
1026 assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
1027 &malformed,
1028 "full payload",
1029 false,
1030 );
1031 }
1032 let mut invalid_header = full.clone();
1033 invalid_header["elapsed_ms"] = json!("invalid");
1034 assert_saved_security_parse_parity::<SavedSecurityEnvelope>(
1035 &invalid_header,
1036 "envelope",
1037 false,
1038 );
1039
1040 let summary = json!({"summary": {
1041 "security_findings": 1,
1042 "by_severity": {"high": 0, "medium": 0, "low": 1},
1043 "by_category": {"dangerous-html": 1},
1044 "by_reachability": SecurityReachabilityCounts::default(),
1045 "by_runtime_state": SecurityRuntimeStateCounts::default(),
1046 "unresolved_edge_files": 0,
1047 "unresolved_callee_sites": 0,
1048 "attack_surface_entries": 0
1049 }});
1050 assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
1051 &summary,
1052 "summary payload",
1053 true,
1054 );
1055 assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
1056 &json!({"summary": false}),
1057 "summary payload",
1058 false,
1059 );
1060 }
1061}