use std::collections::BTreeMap;
use crate::root_envelopes::{attach_telemetry_meta, serialize_named_json_output};
use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
use fallow_types::results::{
SecurityAttackSurfaceEntry, SecurityFinding, SecurityFindingKind, SecurityRuntimeState,
SecuritySeverity, TaintConfidence,
};
use fallow_types::workspace::WorkspaceDiagnostic;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
pub const SECURITY_SCHEMA_VERSION: u32 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum SecuritySchemaVersion {
#[serde(rename = "1")]
V1,
#[serde(rename = "2")]
V2,
#[serde(rename = "3")]
V3,
#[serde(rename = "4")]
V4,
#[serde(rename = "5")]
V5,
#[serde(rename = "6")]
V6,
#[serde(rename = "7")]
V7,
#[serde(rename = "8")]
V8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SecurityGateVerdict {
Pass,
Fail,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityGate<Mode> {
pub mode: Mode,
pub verdict: SecurityGateVerdict,
pub new_count: usize,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schema",
schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
)]
pub struct SecurityOutputConfig<Severity> {
pub rules: SecurityOutputRulesConfig<Severity>,
pub categories_include: Option<Vec<String>>,
pub categories_exclude: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityOutputRulesConfig<Severity> {
pub security_client_server_leak: SecurityRuleSeverityConfig<Severity>,
pub security_sink: SecurityRuleSeverityConfig<Severity>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityRuleSeverityConfig<Severity> {
pub configured: Severity,
pub effective: Severity,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityOutput<Config, Gate> {
pub schema_version: SecuritySchemaVersion,
pub version: ToolVersion,
pub elapsed_ms: ElapsedMs,
pub config: Config,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gate_outcomes: Option<crate::GateOutcomes>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_outcomes: Option<crate::RequestOutcomes>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gate: Option<Gate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
pub security_findings: Vec<SecurityFinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
pub unresolved_edge_files: usize,
pub unresolved_callee_sites: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityUnresolvedCalleeDiagnostics {
pub sampled: Vec<SecurityUnresolvedCalleeSample>,
pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
pub sample_limit: usize,
pub top_files_limit: usize,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityUnresolvedCalleeSample {
pub path: String,
pub line: u32,
pub col: u32,
pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityUnresolvedCalleeTopFile {
pub path: String,
pub count: usize,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityUnresolvedCalleeReasonCount {
pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecuritySummaryOutput<Config, Gate> {
pub schema_version: SecuritySchemaVersion,
pub version: ToolVersion,
pub elapsed_ms: ElapsedMs,
pub config: Config,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gate_outcomes: Option<crate::GateOutcomes>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_outcomes: Option<crate::RequestOutcomes>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gate: Option<Gate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
pub summary: SecuritySummary,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
enum SavedSecurityGateMode {
New,
NewlyReachable,
}
#[derive(Deserialize)]
struct SavedSecurityEnvelope {
#[serde(rename = "version")]
_version: String,
#[serde(rename = "elapsed_ms")]
_elapsed_ms: u64,
#[serde(rename = "config")]
_config: SecurityOutputConfig<fallow_config::Severity>,
#[serde(rename = "_meta", default)]
meta: Option<serde_json::Value>,
#[serde(rename = "gate", default)]
_gate: Option<SecurityGate<SavedSecurityGateMode>>,
}
#[derive(Deserialize)]
struct SavedSecurityFullPayload {
#[serde(rename = "security_findings")]
_security_findings: Vec<SecurityFinding>,
#[serde(rename = "attack_surface", default)]
_attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
#[serde(rename = "unresolved_edge_files")]
_unresolved_edge_files: usize,
#[serde(rename = "unresolved_callee_sites")]
_unresolved_callee_sites: usize,
#[serde(rename = "unresolved_callee_diagnostics", default)]
_unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
}
#[derive(Deserialize)]
struct SavedSecuritySummaryPayload {
#[serde(rename = "summary")]
_summary: SecuritySummary,
}
pub fn validate_saved_security_envelope(value: &serde_json::Value) -> Result<(), String> {
let raw_version = value
.get("schema_version")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "saved security envelope is missing a string `schema_version`".to_owned())?;
let version = raw_version.parse::<u32>().map_err(|_| {
format!("saved security envelope has invalid schema version `{raw_version}`")
})?;
let current = SECURITY_SCHEMA_VERSION;
if version == 0 || version > current {
return Err(format!(
"unsupported saved security schema version {version}; this Fallow version supports versions 1 through {current}"
));
}
if version < current {
return Ok(());
}
let envelope: SavedSecurityEnvelope = parse_saved_security(value, "envelope")?;
if let Some(meta) = envelope.meta {
let meta = meta
.as_object()
.ok_or_else(|| "saved security envelope field `_meta` must be an object".to_owned())?;
if let Some(type_aware) = meta.get("type_aware").filter(|value| !value.is_null()) {
parse_saved_security::<fallow_types::envelope::TypeAwareMeta>(
type_aware,
"type-aware metadata",
)?;
}
}
if value.get("security_findings").is_some() {
parse_saved_security::<SavedSecurityFullPayload>(value, "full payload")?;
} else if value.get("summary").is_some() {
parse_saved_security::<SavedSecuritySummaryPayload>(value, "summary payload")?;
} else {
return Err(
"saved security envelope is missing `security_findings` or `summary`".to_owned(),
);
}
Ok(())
}
fn parse_saved_security<T: DeserializeOwned>(
value: &serde_json::Value,
label: &str,
) -> Result<T, String> {
T::deserialize(value).map_err(|error| {
format!("saved security {label} is incompatible with this Fallow version: {error}")
})
}
#[must_use]
pub fn build_security_summary<Config, Gate>(
output: &SecurityOutput<Config, Gate>,
) -> SecuritySummary {
let mut counts = SecuritySummaryCounts::default();
for finding in &output.security_findings {
counts.record(finding);
}
SecuritySummary {
security_findings: output.security_findings.len(),
by_severity: counts.severity,
by_category: counts.category,
by_reachability: counts.reachability,
by_runtime_state: counts.runtime_state,
unresolved_edge_files: output.unresolved_edge_files,
unresolved_callee_sites: output.unresolved_callee_sites,
attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
}
}
#[derive(Default)]
struct SecuritySummaryCounts {
severity: SecuritySeverityCounts,
category: BTreeMap<String, usize>,
reachability: SecurityReachabilityCounts,
runtime_state: SecurityRuntimeStateCounts,
}
impl SecuritySummaryCounts {
fn record(&mut self, finding: &SecurityFinding) {
record_security_severity(finding.severity, &mut self.severity);
record_security_category(finding, &mut self.category);
record_security_reachability(finding, &mut self.reachability);
record_security_runtime_state(finding, &mut self.runtime_state);
}
}
fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
match severity {
SecuritySeverity::High => by_severity.high += 1,
SecuritySeverity::Medium => by_severity.medium += 1,
SecuritySeverity::Low => by_severity.low += 1,
}
}
fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
let category = finding
.category
.clone()
.unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
*by_category.entry(category).or_insert(0) += 1;
}
fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
match kind {
SecurityFindingKind::ClientServerLeak => "client-server-leak",
SecurityFindingKind::TaintedSink => "tainted-sink",
}
}
fn record_security_reachability(
finding: &SecurityFinding,
by_reachability: &mut SecurityReachabilityCounts,
) {
if finding.source_backed {
by_reachability.source_backed += 1;
}
let Some(reachability) = &finding.reachability else {
return;
};
if reachability.reachable_from_entry {
by_reachability.entry_reachable += 1;
}
if reachability.reachable_from_untrusted_source {
by_reachability.untrusted_source_reachable += 1;
}
if reachability.crosses_boundary {
by_reachability.crosses_boundary += 1;
}
match reachability.taint_confidence {
Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
None => {}
}
}
fn record_security_runtime_state(
finding: &SecurityFinding,
by_runtime_state: &mut SecurityRuntimeStateCounts,
) {
match finding.runtime.as_ref().map(|runtime| runtime.state) {
Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
Some(SecurityRuntimeState::CoverageUnavailable) => {
by_runtime_state.coverage_unavailable += 1;
}
Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
None => by_runtime_state.not_collected += 1,
}
}
pub fn serialize_security_json_output<Config, Gate>(
output: SecurityOutput<Config, Gate>,
analysis_run_id: Option<&str>,
) -> Result<serde_json::Value, serde_json::Error>
where
Config: Serialize,
Gate: Serialize,
{
let mut value = serialize_named_json_output(output, "security")?;
attach_telemetry_meta(&mut value, analysis_run_id);
Ok(value)
}
pub fn serialize_security_summary_json_output<Config, Gate>(
output: &SecurityOutput<Config, Gate>,
analysis_run_id: Option<&str>,
) -> Result<serde_json::Value, serde_json::Error>
where
Config: Clone + Serialize,
Gate: Copy + Serialize,
{
let summary = SecuritySummaryOutput {
schema_version: output.schema_version,
version: output.version.clone(),
elapsed_ms: output.elapsed_ms,
config: output.config.clone(),
gate_outcomes: output.gate_outcomes.clone(),
request_outcomes: output.request_outcomes.clone(),
meta: output.meta.clone(),
gate: output.gate,
workspace_diagnostics: output.workspace_diagnostics.clone(),
summary: build_security_summary(output),
};
let mut value = serialize_named_json_output(summary, "security")?;
attach_telemetry_meta(&mut value, analysis_run_id);
Ok(value)
}
pub fn serialize_security_survivors_json_output(
output: SecuritySurvivorsOutput,
) -> Result<serde_json::Value, serde_json::Error> {
serialize_named_json_output(output, "security-survivors")
}
pub fn serialize_security_blind_spots_json_output(
output: SecurityBlindSpotsOutput,
) -> Result<serde_json::Value, serde_json::Error> {
serialize_named_json_output(output, "security-blind-spots")
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecuritySummary {
pub security_findings: usize,
pub by_severity: SecuritySeverityCounts,
pub by_category: BTreeMap<String, usize>,
pub by_reachability: SecurityReachabilityCounts,
pub by_runtime_state: SecurityRuntimeStateCounts,
pub unresolved_edge_files: usize,
pub unresolved_callee_sites: usize,
pub attack_surface_entries: usize,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecuritySeverityCounts {
pub high: usize,
pub medium: usize,
pub low: usize,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityReachabilityCounts {
pub entry_reachable: usize,
pub untrusted_source_reachable: usize,
pub arg_level: usize,
pub module_level: usize,
pub crosses_boundary: usize,
pub source_backed: usize,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityRuntimeStateCounts {
pub runtime_hot: usize,
pub runtime_cold: usize,
pub never_executed: usize,
pub low_traffic: usize,
pub coverage_unavailable: usize,
pub runtime_unknown: usize,
pub not_collected: usize,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum SecuritySurvivorsSchemaVersion {
#[serde(rename = "2")]
V2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SecurityVerifierVerdictStatus {
Survivor,
Dismissed,
NeedsHumanReview,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityVerifierVerdict {
pub schema_version: String,
pub finding_id: String,
pub verdict: SecurityVerifierVerdictStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub impact: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fix_direction: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecuritySurvivorsOutput {
pub schema_version: SecuritySurvivorsSchemaVersion,
pub version: ToolVersion,
pub elapsed_ms: ElapsedMs,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
pub summary: SecuritySurvivorsSummary,
pub survivors: BTreeMap<String, SecuritySurvivor>,
pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecuritySurvivorsSummary {
pub candidates: usize,
pub verdicts: usize,
pub survivors: usize,
pub dismissed: usize,
pub needs_human_review: usize,
pub unverdicted: usize,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecuritySurvivor {
pub finding_id: String,
pub verdict: SecurityVerifierVerdictStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub impact: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fix_direction: Option<String>,
pub candidate: SecurityFinding,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum SecurityBlindSpotsSchemaVersion {
#[serde(rename = "1")]
V1,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityBlindSpotsOutput {
pub schema_version: SecurityBlindSpotsSchemaVersion,
pub version: ToolVersion,
pub elapsed_ms: ElapsedMs,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
pub summary: SecurityBlindSpotsSummary,
pub groups: Vec<SecurityBlindSpotGroup>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityBlindSpotsSummary {
pub unresolved_edge_files: usize,
pub unresolved_callee_sites: usize,
pub sampled_callee_sites: usize,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityBlindSpotGroup {
pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
pub sampled_count: usize,
pub files: Vec<SecurityBlindSpotFile>,
pub suggestion: String,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SecurityBlindSpotFile {
pub path: String,
pub sampled_count: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn current_security_envelope() -> serde_json::Value {
json!({
"schema_version": "8",
"version": "test",
"elapsed_ms": 1,
"config": {
"rules": {
"security_client_server_leak": {
"configured": "warn",
"effective": "warn"
},
"security_sink": {
"configured": "warn",
"effective": "warn"
}
},
"categories_include": null,
"categories_exclude": null
},
"security_findings": [],
"unresolved_edge_files": 0,
"unresolved_callee_sites": 0
})
}
#[test]
fn security_summary_json_output_uses_security_root_contract() {
let output = SecurityOutput {
gate_outcomes: None,
request_outcomes: None,
schema_version: SecuritySchemaVersion::V8,
version: ToolVersion("test".to_string()),
elapsed_ms: ElapsedMs(12),
config: json!({"rules": {}}),
meta: None,
gate: None::<()>,
workspace_diagnostics: vec![WorkspaceDiagnostic::new(
std::path::Path::new("/project"),
std::path::PathBuf::from("package.json"),
fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
)],
security_findings: Vec::new(),
attack_surface: None,
unresolved_edge_files: 2,
unresolved_callee_sites: 3,
unresolved_callee_diagnostics: None,
};
let value = serialize_security_summary_json_output(&output, None)
.expect("security summary should serialize");
assert_eq!(value["kind"], "security");
assert_eq!(value["schema_version"], "8");
assert_eq!(value["summary"]["security_findings"], 0);
assert_eq!(value["summary"]["unresolved_edge_files"], 2);
assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
assert_eq!(value["workspace_diagnostics"][0]["path"], "package.json");
assert!(value.get("security_findings").is_none());
}
#[test]
fn saved_security_validator_accepts_current_full_and_summary_payloads() {
let full = current_security_envelope();
validate_saved_security_envelope(&full).expect("current full security envelope");
let mut summary = current_security_envelope();
summary
.as_object_mut()
.expect("security envelope")
.remove("security_findings");
summary
.as_object_mut()
.expect("security envelope")
.remove("unresolved_edge_files");
summary
.as_object_mut()
.expect("security envelope")
.remove("unresolved_callee_sites");
summary["summary"] = serde_json::to_value(SecuritySummary {
security_findings: 0,
by_severity: SecuritySeverityCounts::default(),
by_category: BTreeMap::new(),
by_reachability: SecurityReachabilityCounts::default(),
by_runtime_state: SecurityRuntimeStateCounts::default(),
unresolved_edge_files: 0,
unresolved_callee_sites: 0,
attack_surface_entries: 0,
})
.expect("security summary");
validate_saved_security_envelope(&summary).expect("current summary security envelope");
}
#[test]
fn saved_security_validator_accepts_legacy_v7_type_aware_payload() {
let mut envelope = current_security_envelope();
envelope["schema_version"] = json!("7");
envelope["_meta"] = json!({
"type_aware": {
"executed": true,
"protocol_version": 6,
"sidecar_version": "0.6.0",
"backend": "typescript-go",
"backend_version": "7.0.0-dev",
"selected_tsconfigs": ["tsconfig.json"],
"candidate_count": 1,
"confirmed_used_count": 0,
"contract_preserved_count": 0,
"no_static_references_count": 1,
"fix_eligible_count": 0,
"unresolved_count": 0,
"abstained_count": 0,
"abstention_reasons": {
"no_project": 0,
"ambiguous_project": 0,
"blocking_diagnostics": 0,
"svelte_virtual_module_exports": 0,
"unknown_symbol": 0,
"unsupported_syntax": 0,
"capacity": 0
},
"projects": [],
"warning_count": 0,
"warnings": [],
"elapsed_ms": 4,
"phase_timings_ms": {
"project_setup": 1,
"diagnostics": 1,
"symbol_scan": 2
}
}
});
validate_saved_security_envelope(&envelope)
.expect("legacy schema 7 type-aware metadata stays readable");
}
#[test]
fn saved_security_validator_rejects_malformed_current_payloads() {
let mut findings = current_security_envelope();
findings["security_findings"] = json!("not-an-array");
assert!(validate_saved_security_envelope(&findings).is_err());
let mut type_aware = current_security_envelope();
type_aware["_meta"] = json!({"type_aware": {"queries": "not-an-array"}});
assert!(validate_saved_security_envelope(&type_aware).is_err());
}
#[test]
fn saved_security_validator_accepts_legacy_and_rejects_future_schema() {
validate_saved_security_envelope(&json!({"schema_version": "7"}))
.expect("known legacy security schema");
let error = validate_saved_security_envelope(&json!({"schema_version": "9"}))
.expect_err("future security schema must fail closed");
assert!(error.contains("unsupported saved security schema version 9"));
}
fn assert_saved_security_parse_parity<T: DeserializeOwned>(
value: &serde_json::Value,
label: &str,
valid: bool,
) {
let owned = serde_json::from_value::<T>(value.clone())
.map(|_| ())
.map_err(|error| {
format!("saved security {label} is incompatible with this Fallow version: {error}")
});
let borrowed = parse_saved_security::<T>(value, label).map(|_| ());
assert_eq!(borrowed.is_ok(), valid, "{label}: {borrowed:?}");
assert_eq!(
borrowed, owned,
"{label} must retain exact validation errors"
);
}
#[test]
fn saved_security_parsing_preserves_owned_value_contract() {
let mut full = current_security_envelope();
full["security_findings"] = json!([{
"finding_id": "security-fixture",
"kind": "tainted-sink",
"path": "src/[route]/café.ts",
"line": 12,
"col": 3,
"evidence": "quoted \"value\" and unicode café",
"severity": "low",
"trace": [],
"actions": [],
"candidate": fallow_types::results::SecurityCandidate::default()
}]);
full["future_field"] = json!({"nested": ["ignored", {"value": null}]});
assert_saved_security_parse_parity::<SavedSecurityEnvelope>(&full, "envelope", true);
assert_saved_security_parse_parity::<SavedSecurityFullPayload>(&full, "full payload", true);
validate_saved_security_envelope(&full).expect("nonempty current payload remains valid");
for (field, invalid) in [
("line", json!(-1)),
("severity", json!("invalid")),
("candidate", json!([])),
] {
let mut malformed = full.clone();
malformed["security_findings"][0][field] = invalid;
assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
&malformed,
"full payload",
false,
);
assert!(validate_saved_security_envelope(&malformed).is_err());
}
for malformed in [
serde_json::Value::Null,
json!({}),
json!({"security_findings": "not-an-array"}),
] {
assert_saved_security_parse_parity::<SavedSecurityFullPayload>(
&malformed,
"full payload",
false,
);
}
let mut invalid_header = full.clone();
invalid_header["elapsed_ms"] = json!("invalid");
assert_saved_security_parse_parity::<SavedSecurityEnvelope>(
&invalid_header,
"envelope",
false,
);
let summary = json!({"summary": {
"security_findings": 1,
"by_severity": {"high": 0, "medium": 0, "low": 1},
"by_category": {"dangerous-html": 1},
"by_reachability": SecurityReachabilityCounts::default(),
"by_runtime_state": SecurityRuntimeStateCounts::default(),
"unresolved_edge_files": 0,
"unresolved_callee_sites": 0,
"attack_surface_entries": 0
}});
assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
&summary,
"summary payload",
true,
);
assert_saved_security_parse_parity::<SavedSecuritySummaryPayload>(
&json!({"summary": false}),
"summary payload",
false,
);
}
}