Skip to main content

fallow_output/
security.rs

1//! Security command output contracts.
2
3use 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 serde::{Deserialize, Serialize, de::DeserializeOwned};
12
13/// Current `fallow security --format json` schema version.
14pub const SECURITY_SCHEMA_VERSION: u32 = 8;
15
16/// The `fallow security --format json` schema version. Independently versioned
17/// from the main contract, mirroring `ImpactReportSchemaVersion`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20pub enum SecuritySchemaVersion {
21    /// First release of the `fallow security --format json` shape.
22    #[serde(rename = "1")]
23    V1,
24    /// Adds per-finding `severity` for verification-priority tiering.
25    #[serde(rename = "2")]
26    V2,
27    /// Adds version, elapsed time, explain metadata, and safe config metadata.
28    #[serde(rename = "3")]
29    V3,
30    /// Adds bounded diagnostics for unresolved callee blind spots.
31    #[serde(rename = "4")]
32    V4,
33    /// Adds summary metadata to security summary JSON.
34    #[serde(rename = "5")]
35    V5,
36    /// Adds `candidate.sink.url_shape` for URL-shaped security candidates.
37    #[serde(rename = "6")]
38    V6,
39    /// Adds the server-only-import category on client-server-leak findings.
40    #[serde(rename = "7")]
41    V7,
42    /// Expands the required semantic omission reason-code enum.
43    #[serde(rename = "8")]
44    V8,
45}
46
47/// Gate verdict on the wire. `fail` is the CI-state token; human output renders
48/// it as "REVIEW REQUIRED" because these stay unverified candidates, never
49/// confirmed vulnerabilities.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52#[serde(rename_all = "kebab-case")]
53pub enum SecurityGateVerdict {
54    /// No new candidate in the changed lines.
55    Pass,
56    /// At least one new candidate in the changed lines.
57    Fail,
58}
59
60/// The `gate` block on `SecurityOutput`, present only when `--gate <mode>` ran.
61/// Invariant: `verdict == Fail  IFF  exit code 8  IFF  new_count > 0`.
62#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64pub struct SecurityGate<Mode> {
65    /// Gate mode that was selected on the command line.
66    pub mode: Mode,
67    /// Gate outcome for this run.
68    pub verdict: SecurityGateVerdict,
69    /// Number of candidates matching the selected gate mode.
70    pub new_count: usize,
71}
72
73/// Allowlisted config context for `fallow security --format json`.
74#[derive(Debug, Clone, Deserialize, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76#[cfg_attr(
77    feature = "schema",
78    schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
79)]
80pub struct SecurityOutputConfig<Severity> {
81    /// Relevant rule severities before and after this command applies its
82    /// default-on behavior for security-only rules.
83    pub rules: SecurityOutputRulesConfig<Severity>,
84    /// `security.categories.include` from config. `null` means unset, `[]`
85    /// means explicitly empty.
86    pub categories_include: Option<Vec<String>>,
87    /// `security.categories.exclude` from config. `null` means unset, `[]`
88    /// means explicitly empty.
89    pub categories_exclude: Option<Vec<String>>,
90}
91
92/// Per-rule severity context inside [`SecurityOutputConfig::rules`].
93#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95pub struct SecurityOutputRulesConfig<Severity> {
96    /// Severity context for the client-server-leak rule.
97    pub security_client_server_leak: SecurityRuleSeverityConfig<Severity>,
98    /// Severity context for the security-sink rule.
99    pub security_sink: SecurityRuleSeverityConfig<Severity>,
100}
101
102/// Configured-versus-effective severity for one security rule.
103#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105pub struct SecurityRuleSeverityConfig<Severity> {
106    /// Severity read from resolved config before the security command applies
107    /// its default-on behavior.
108    pub configured: Severity,
109    /// Severity used for this command run.
110    pub effective: Severity,
111}
112
113/// The `fallow security --format json` envelope. `FallowOutput` discriminates it
114/// by the `kind: "security"` tag; the optional `gate` block is additive and is
115/// not part of that discrimination.
116#[derive(Debug, Clone, Serialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct SecurityOutput<Config, Gate> {
119    /// Schema version of this envelope.
120    pub schema_version: SecuritySchemaVersion,
121    /// Fallow CLI version that produced this output.
122    pub version: ToolVersion,
123    /// Wall-clock milliseconds spent producing the report.
124    pub elapsed_ms: ElapsedMs,
125    /// Privacy-safe config context relevant to security candidate generation.
126    pub config: Config,
127    /// Security-specific rule and field metadata, emitted with `--explain`.
128    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
129    pub meta: Option<Meta>,
130    /// Gate verdict, present only when `--gate <mode>` was set (issue #886).
131    /// Emitted on pass too (`verdict: "pass"`, `new_count: 0`) so consumers
132    /// distinguish "gate ran and passed" from "gate did not run" (absent).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub gate: Option<Gate>,
135    /// Security candidates. Paths are project-root-relative, forward-slash.
136    pub security_findings: Vec<SecurityFinding>,
137    /// Opt-in attack-surface inventory from untrusted entry points to reachable
138    /// sinks. Present only when `--surface` was requested.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
141    /// In-band blind spot: number of `"use client"` files whose transitive
142    /// import cone contains a dynamic `import()` the reachability BFS could not
143    /// follow. A leak hidden behind such an edge would not be reported, so a
144    /// zero finding count with a non-zero value here is NOT a clean bill.
145    pub unresolved_edge_files: usize,
146    /// In-band blind spot: number of sink-shaped nodes the catalogue detector
147    /// could not flatten to a static callee path (dynamic dispatch, computed
148    /// members, aliased bindings). A zero finding count with a non-zero value
149    /// here is NOT a clean bill.
150    pub unresolved_callee_sites: usize,
151    /// Bounded diagnostics for unresolved callee blind spots.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
154}
155
156/// Bounded unresolved-callee diagnostics for `fallow security --format json`.
157#[derive(Debug, Clone, Deserialize, Serialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159pub struct SecurityUnresolvedCalleeDiagnostics {
160    /// Deterministic sample rows, capped by `sample_limit`.
161    pub sampled: Vec<SecurityUnresolvedCalleeSample>,
162    /// Files with the most unresolved callees, capped by `top_files_limit`.
163    pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
164    /// Full count by unresolved-callee reason, sorted by count then reason.
165    pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
166    /// Maximum number of sample rows emitted.
167    pub sample_limit: usize,
168    /// Maximum number of top-file rows emitted.
169    pub top_files_limit: usize,
170}
171
172/// One sampled unresolved-callee row.
173#[derive(Debug, Clone, Deserialize, Serialize)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175pub struct SecurityUnresolvedCalleeSample {
176    /// File path relative to the analysed root.
177    pub path: String,
178    /// 1-based line of the skipped call site.
179    pub line: u32,
180    /// 1-based column of the skipped call site.
181    pub col: u32,
182    /// Why the callee could not be resolved.
183    pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
184    /// Compact syntax shape of the skipped callee.
185    pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
186}
187
188/// Count of unresolved callees in one file.
189#[derive(Debug, Clone, Deserialize, Serialize)]
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191pub struct SecurityUnresolvedCalleeTopFile {
192    /// File path relative to the analysed root.
193    pub path: String,
194    /// Number of unresolved callees in this file.
195    pub count: usize,
196}
197
198/// Count of unresolved callees for one reason.
199#[derive(Debug, Clone, Deserialize, Serialize)]
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201pub struct SecurityUnresolvedCalleeReasonCount {
202    /// Why the callees could not be resolved.
203    pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
204    /// Number of unresolved callees with this reason.
205    pub count: usize,
206}
207
208/// Compact `fallow security --summary --format json` payload. Uses the same
209/// `kind: "security"` discriminator as the full payload, but omits candidate
210/// arrays and exposes only aggregate counts.
211#[derive(Debug, Clone, Serialize)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213pub struct SecuritySummaryOutput<Config, Gate> {
214    /// Schema version of this envelope.
215    pub schema_version: SecuritySchemaVersion,
216    /// Fallow CLI version that produced this output.
217    pub version: ToolVersion,
218    /// Wall-clock milliseconds spent producing the report.
219    pub elapsed_ms: ElapsedMs,
220    /// Privacy-safe config context relevant to security candidate generation.
221    pub config: Config,
222    /// Security-specific rule and field metadata, emitted with `--explain`.
223    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
224    pub meta: Option<Meta>,
225    /// Gate verdict, present only when `--gate <mode>` was set.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub gate: Option<Gate>,
228    /// Aggregate security counts after all filters, gates, and scopes.
229    pub summary: SecuritySummary,
230}
231
232#[derive(Deserialize)]
233#[serde(rename_all = "kebab-case")]
234enum SavedSecurityGateMode {
235    New,
236    NewlyReachable,
237}
238
239#[derive(Deserialize)]
240struct SavedSecurityEnvelope {
241    #[serde(rename = "version")]
242    _version: String,
243    #[serde(rename = "elapsed_ms")]
244    _elapsed_ms: u64,
245    #[serde(rename = "config")]
246    _config: SecurityOutputConfig<fallow_config::Severity>,
247    #[serde(rename = "_meta", default)]
248    meta: Option<serde_json::Value>,
249    #[serde(rename = "gate", default)]
250    _gate: Option<SecurityGate<SavedSecurityGateMode>>,
251}
252
253#[derive(Deserialize)]
254struct SavedSecurityFullPayload {
255    #[serde(rename = "security_findings")]
256    _security_findings: Vec<SecurityFinding>,
257    #[serde(rename = "attack_surface", default)]
258    _attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
259    #[serde(rename = "unresolved_edge_files")]
260    _unresolved_edge_files: usize,
261    #[serde(rename = "unresolved_callee_sites")]
262    _unresolved_callee_sites: usize,
263    #[serde(rename = "unresolved_callee_diagnostics", default)]
264    _unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
265}
266
267#[derive(Deserialize)]
268struct SavedSecuritySummaryPayload {
269    #[serde(rename = "summary")]
270    _summary: SecuritySummary,
271}
272
273/// Validate a saved security envelope against the current output-owned schema.
274///
275/// Older known schemas remain readable for compatibility. Current envelopes
276/// fail closed when required fields or nested payloads are malformed.
277pub fn validate_saved_security_envelope(value: &serde_json::Value) -> Result<(), String> {
278    let raw_version = value
279        .get("schema_version")
280        .and_then(serde_json::Value::as_str)
281        .ok_or_else(|| "saved security envelope is missing a string `schema_version`".to_owned())?;
282    let version = raw_version.parse::<u32>().map_err(|_| {
283        format!("saved security envelope has invalid schema version `{raw_version}`")
284    })?;
285    let current = SECURITY_SCHEMA_VERSION;
286    if version == 0 || version > current {
287        return Err(format!(
288            "unsupported saved security schema version {version}; this Fallow version supports versions 1 through {current}"
289        ));
290    }
291    if version < current {
292        return Ok(());
293    }
294
295    let envelope: SavedSecurityEnvelope = parse_saved_security(value, "envelope")?;
296    if let Some(meta) = envelope.meta {
297        let meta = meta
298            .as_object()
299            .ok_or_else(|| "saved security envelope field `_meta` must be an object".to_owned())?;
300        if let Some(type_aware) = meta.get("type_aware").filter(|value| !value.is_null()) {
301            parse_saved_security::<fallow_types::envelope::TypeAwareMeta>(
302                type_aware,
303                "type-aware metadata",
304            )?;
305        }
306    }
307
308    if value.get("security_findings").is_some() {
309        parse_saved_security::<SavedSecurityFullPayload>(value, "full payload")?;
310    } else if value.get("summary").is_some() {
311        parse_saved_security::<SavedSecuritySummaryPayload>(value, "summary payload")?;
312    } else {
313        return Err(
314            "saved security envelope is missing `security_findings` or `summary`".to_owned(),
315        );
316    }
317    Ok(())
318}
319
320fn parse_saved_security<T: DeserializeOwned>(
321    value: &serde_json::Value,
322    label: &str,
323) -> Result<T, String> {
324    serde_json::from_value(value.clone()).map_err(|error| {
325        format!("saved security {label} is incompatible with this Fallow version: {error}")
326    })
327}
328
329/// Build the compact aggregate payload for `fallow security --summary --format json`.
330#[must_use]
331pub fn build_security_summary<Config, Gate>(
332    output: &SecurityOutput<Config, Gate>,
333) -> SecuritySummary {
334    let mut counts = SecuritySummaryCounts::default();
335
336    for finding in &output.security_findings {
337        counts.record(finding);
338    }
339
340    SecuritySummary {
341        security_findings: output.security_findings.len(),
342        by_severity: counts.severity,
343        by_category: counts.category,
344        by_reachability: counts.reachability,
345        by_runtime_state: counts.runtime_state,
346        unresolved_edge_files: output.unresolved_edge_files,
347        unresolved_callee_sites: output.unresolved_callee_sites,
348        attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
349    }
350}
351
352#[derive(Default)]
353struct SecuritySummaryCounts {
354    severity: SecuritySeverityCounts,
355    category: BTreeMap<String, usize>,
356    reachability: SecurityReachabilityCounts,
357    runtime_state: SecurityRuntimeStateCounts,
358}
359
360impl SecuritySummaryCounts {
361    fn record(&mut self, finding: &SecurityFinding) {
362        record_security_severity(finding.severity, &mut self.severity);
363        record_security_category(finding, &mut self.category);
364        record_security_reachability(finding, &mut self.reachability);
365        record_security_runtime_state(finding, &mut self.runtime_state);
366    }
367}
368
369fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
370    match severity {
371        SecuritySeverity::High => by_severity.high += 1,
372        SecuritySeverity::Medium => by_severity.medium += 1,
373        SecuritySeverity::Low => by_severity.low += 1,
374    }
375}
376
377fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
378    let category = finding
379        .category
380        .clone()
381        .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
382    *by_category.entry(category).or_insert(0) += 1;
383}
384
385fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
386    match kind {
387        SecurityFindingKind::ClientServerLeak => "client-server-leak",
388        SecurityFindingKind::TaintedSink => "tainted-sink",
389    }
390}
391
392fn record_security_reachability(
393    finding: &SecurityFinding,
394    by_reachability: &mut SecurityReachabilityCounts,
395) {
396    if finding.source_backed {
397        by_reachability.source_backed += 1;
398    }
399    let Some(reachability) = &finding.reachability else {
400        return;
401    };
402
403    if reachability.reachable_from_entry {
404        by_reachability.entry_reachable += 1;
405    }
406    if reachability.reachable_from_untrusted_source {
407        by_reachability.untrusted_source_reachable += 1;
408    }
409    if reachability.crosses_boundary {
410        by_reachability.crosses_boundary += 1;
411    }
412    match reachability.taint_confidence {
413        Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
414        Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
415        None => {}
416    }
417}
418
419fn record_security_runtime_state(
420    finding: &SecurityFinding,
421    by_runtime_state: &mut SecurityRuntimeStateCounts,
422) {
423    match finding.runtime.as_ref().map(|runtime| runtime.state) {
424        Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
425        Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
426        Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
427        Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
428        Some(SecurityRuntimeState::CoverageUnavailable) => {
429            by_runtime_state.coverage_unavailable += 1;
430        }
431        Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
432        None => by_runtime_state.not_collected += 1,
433    }
434}
435
436/// Serialize the full `fallow security --format json` envelope.
437///
438/// # Errors
439///
440/// Returns a serde error when the envelope cannot be converted to JSON.
441pub fn serialize_security_json_output<Config, Gate>(
442    output: SecurityOutput<Config, Gate>,
443    mode: RootEnvelopeMode,
444    analysis_run_id: Option<&str>,
445) -> Result<serde_json::Value, serde_json::Error>
446where
447    Config: Serialize,
448    Gate: Serialize,
449{
450    let mut value = serialize_named_json_output(output, "security", mode)?;
451    attach_telemetry_meta(&mut value, analysis_run_id);
452    Ok(value)
453}
454
455/// Serialize the compact `fallow security --summary --format json` envelope.
456///
457/// # Errors
458///
459/// Returns a serde error when the envelope cannot be converted to JSON.
460pub fn serialize_security_summary_json_output<Config, Gate>(
461    output: &SecurityOutput<Config, Gate>,
462    mode: RootEnvelopeMode,
463    analysis_run_id: Option<&str>,
464) -> Result<serde_json::Value, serde_json::Error>
465where
466    Config: Clone + Serialize,
467    Gate: Copy + Serialize,
468{
469    let summary = SecuritySummaryOutput {
470        schema_version: output.schema_version,
471        version: output.version.clone(),
472        elapsed_ms: output.elapsed_ms,
473        config: output.config.clone(),
474        meta: output.meta.clone(),
475        gate: output.gate,
476        summary: build_security_summary(output),
477    };
478    let mut value = serialize_named_json_output(summary, "security", mode)?;
479    attach_telemetry_meta(&mut value, analysis_run_id);
480    Ok(value)
481}
482
483/// Serialize the `fallow security survivors --format json` envelope.
484///
485/// # Errors
486///
487/// Returns a serde error when the envelope cannot be converted to JSON.
488pub fn serialize_security_survivors_json_output(
489    output: SecuritySurvivorsOutput,
490    mode: RootEnvelopeMode,
491) -> Result<serde_json::Value, serde_json::Error> {
492    serialize_named_json_output(output, "security-survivors", mode)
493}
494
495/// Serialize the `fallow security blind-spots --format json` envelope.
496///
497/// # Errors
498///
499/// Returns a serde error when the envelope cannot be converted to JSON.
500pub fn serialize_security_blind_spots_json_output(
501    output: SecurityBlindSpotsOutput,
502    mode: RootEnvelopeMode,
503) -> Result<serde_json::Value, serde_json::Error> {
504    serialize_named_json_output(output, "security-blind-spots", mode)
505}
506
507/// Aggregate counts for `fallow security --summary --format json`.
508#[derive(Debug, Clone, Deserialize, Serialize)]
509#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
510pub struct SecuritySummary {
511    /// Number of security candidates after all filters, gates, and scopes.
512    pub security_findings: usize,
513    /// Fixed severity counts for the closed security severity enum.
514    pub by_severity: SecuritySeverityCounts,
515    /// Finding counts by catalogue category, or by kind for findings without a
516    /// catalogue category.
517    pub by_category: BTreeMap<String, usize>,
518    /// Fixed reachability counts for ranking and triage signals.
519    pub by_reachability: SecurityReachabilityCounts,
520    /// Fixed runtime coverage counts for runtime-state triage signals.
521    pub by_runtime_state: SecurityRuntimeStateCounts,
522    /// Number of client files whose dynamic imports could not be followed.
523    pub unresolved_edge_files: usize,
524    /// Number of sink-shaped callees that could not be statically flattened.
525    pub unresolved_callee_sites: usize,
526    /// Number of attack-surface entries included in the prepared full output.
527    pub attack_surface_entries: usize,
528}
529
530/// Fixed severity counters for summary JSON.
531#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
532#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
533pub struct SecuritySeverityCounts {
534    /// High-severity candidates.
535    pub high: usize,
536    /// Medium-severity candidates.
537    pub medium: usize,
538    /// Low-severity candidates.
539    pub low: usize,
540}
541
542/// Fixed reachability counters for summary JSON.
543#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
544#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
545pub struct SecurityReachabilityCounts {
546    /// Candidates reachable from an entry point.
547    pub entry_reachable: usize,
548    /// Candidates reachable from an untrusted input source.
549    pub untrusted_source_reachable: usize,
550    /// Candidates where taint flows through a call argument.
551    pub arg_level: usize,
552    /// Candidates where taint is only module-level.
553    pub module_level: usize,
554    /// Candidates whose flow crosses a client/server boundary.
555    pub crosses_boundary: usize,
556    /// Candidates backed by a concrete taint source.
557    pub source_backed: usize,
558}
559
560/// Fixed runtime coverage counters for summary JSON.
561#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
563pub struct SecurityRuntimeStateCounts {
564    /// Candidates on frequently executed runtime paths.
565    pub runtime_hot: usize,
566    /// Candidates on rarely executed runtime paths.
567    pub runtime_cold: usize,
568    /// Candidates on paths never seen executing.
569    pub never_executed: usize,
570    /// Candidates on low-traffic paths.
571    pub low_traffic: usize,
572    /// Candidates in files runtime coverage did not observe.
573    pub coverage_unavailable: usize,
574    /// Candidates whose runtime state could not be classified.
575    pub runtime_unknown: usize,
576    /// Candidates analysed without any runtime coverage data.
577    pub not_collected: usize,
578}
579
580/// The `fallow security survivors --format json` schema version.
581#[derive(Debug, Clone, Copy, Serialize)]
582#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
583pub enum SecuritySurvivorsSchemaVersion {
584    /// Adds `summary.unverdicted` for incomplete verdict files.
585    #[serde(rename = "2")]
586    V2,
587}
588
589/// Verifier verdict status accepted by `fallow security survivors`.
590#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
591#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
592#[serde(rename_all = "kebab-case")]
593pub enum SecurityVerifierVerdictStatus {
594    /// The verifier could not dismiss the candidate from supplied evidence.
595    Survivor,
596    /// The verifier dismissed the candidate from supplied evidence.
597    Dismissed,
598    /// The verifier needs human review before dismissal or remediation.
599    NeedsHumanReview,
600}
601
602/// One supported verifier verdict input row.
603#[derive(Debug, Clone, Deserialize, Serialize)]
604#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
605pub struct SecurityVerifierVerdict {
606    /// Must be `fallow-security-verdict/v1`.
607    pub schema_version: String,
608    /// Stable candidate id from `security_findings[].finding_id`.
609    pub finding_id: String,
610    /// Verifier's verdict for the candidate.
611    pub verdict: SecurityVerifierVerdictStatus,
612    /// Short machine-oriented verdict reason.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub reason: Option<String>,
615    /// Longer free-form verdict explanation.
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub rationale: Option<String>,
618    /// Optional verifier-provided confidence or review priority.
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub confidence: Option<String>,
621    /// Optional verifier-provided impact statement.
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub impact: Option<String>,
624    /// Optional verifier-owned remediation direction.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub fix_direction: Option<String>,
627}
628
629/// The `fallow security survivors --format json` envelope.
630#[derive(Debug, Clone, Serialize)]
631#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
632pub struct SecuritySurvivorsOutput {
633    /// Schema version of this envelope.
634    pub schema_version: SecuritySurvivorsSchemaVersion,
635    /// Fallow CLI version that produced this output.
636    pub version: ToolVersion,
637    /// Wall-clock milliseconds spent producing the report.
638    pub elapsed_ms: ElapsedMs,
639    /// Aggregate verdict counts.
640    pub summary: SecuritySurvivorsSummary,
641    /// Verifier-retained candidates keyed by finding id.
642    pub survivors: BTreeMap<String, SecuritySurvivor>,
643    /// Ambiguous candidates keyed by finding id. These are not dismissed and are
644    /// kept explicit so queues can decide whether to include them.
645    pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
646}
647
648/// Aggregate counts for survivor rendering.
649#[derive(Debug, Clone, Copy, Serialize)]
650#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
651pub struct SecuritySurvivorsSummary {
652    /// Candidates in the input security report.
653    pub candidates: usize,
654    /// Verifier verdicts supplied.
655    pub verdicts: usize,
656    /// Candidates the verifier retained.
657    pub survivors: usize,
658    /// Candidates the verifier dismissed.
659    pub dismissed: usize,
660    /// Candidates the verifier flagged as ambiguous.
661    pub needs_human_review: usize,
662    /// Candidates without any verdict.
663    pub unverdicted: usize,
664}
665
666/// One verifier-retained candidate row.
667#[derive(Debug, Clone, Serialize)]
668#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
669pub struct SecuritySurvivor {
670    /// Stable candidate id from `security_findings[].finding_id`.
671    pub finding_id: String,
672    /// Verifier's verdict for the candidate.
673    pub verdict: SecurityVerifierVerdictStatus,
674    /// Short machine-oriented verdict reason.
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub reason: Option<String>,
677    /// Longer free-form verdict explanation.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub rationale: Option<String>,
680    /// Optional verifier-provided confidence or review priority.
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub confidence: Option<String>,
683    /// Optional verifier-provided impact statement.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub impact: Option<String>,
686    /// Optional verifier-owned remediation direction.
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub fix_direction: Option<String>,
689    /// Original typed fallow security candidate.
690    pub candidate: SecurityFinding,
691}
692
693/// The `fallow security blind-spots --format json` schema version.
694#[derive(Debug, Clone, Copy, Serialize)]
695#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
696pub enum SecurityBlindSpotsSchemaVersion {
697    /// Initial blind-spot grouping output contract.
698    #[serde(rename = "1")]
699    V1,
700}
701
702/// The `fallow security blind-spots --format json` envelope.
703#[derive(Debug, Clone, Serialize)]
704#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
705pub struct SecurityBlindSpotsOutput {
706    /// Schema version of this envelope.
707    pub schema_version: SecurityBlindSpotsSchemaVersion,
708    /// Fallow CLI version that produced this output.
709    pub version: ToolVersion,
710    /// Wall-clock milliseconds spent producing the report.
711    pub elapsed_ms: ElapsedMs,
712    /// Aggregate blind-spot counts from the security analysis.
713    pub summary: SecurityBlindSpotsSummary,
714    /// Grouped unresolved callee diagnostics, derived from existing samples.
715    pub groups: Vec<SecurityBlindSpotGroup>,
716}
717
718/// Aggregate counts for blind-spot output.
719#[derive(Debug, Clone, Copy, Serialize)]
720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
721pub struct SecurityBlindSpotsSummary {
722    /// Files containing at least one unresolved callee.
723    pub unresolved_edge_files: usize,
724    /// Total unresolved callee sites in the analysis.
725    pub unresolved_callee_sites: usize,
726    /// Callee sites captured in the bounded diagnostic sample.
727    pub sampled_callee_sites: usize,
728}
729
730/// One actionable blind-spot group.
731#[derive(Debug, Clone, Serialize)]
732#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
733pub struct SecurityBlindSpotGroup {
734    /// Why the callees in this group could not be resolved.
735    pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
736    /// Compact syntax shape of the skipped callee.
737    pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
738    /// Count in the bounded diagnostic sample.
739    pub sampled_count: usize,
740    /// Top files in this bounded diagnostic sample.
741    pub files: Vec<SecurityBlindSpotFile>,
742    /// Suggested next action for this group.
743    pub suggestion: String,
744}
745
746/// One file inside a blind-spot group.
747#[derive(Debug, Clone, Serialize)]
748#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
749pub struct SecurityBlindSpotFile {
750    /// File path relative to the analysed root.
751    pub path: String,
752    /// Count in the bounded diagnostic sample.
753    pub sampled_count: usize,
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use serde_json::json;
760
761    fn current_security_envelope() -> serde_json::Value {
762        json!({
763            "schema_version": "8",
764            "version": "test",
765            "elapsed_ms": 1,
766            "config": {
767                "rules": {
768                    "security_client_server_leak": {
769                        "configured": "warn",
770                        "effective": "warn"
771                    },
772                    "security_sink": {
773                        "configured": "warn",
774                        "effective": "warn"
775                    }
776                },
777                "categories_include": null,
778                "categories_exclude": null
779            },
780            "security_findings": [],
781            "unresolved_edge_files": 0,
782            "unresolved_callee_sites": 0
783        })
784    }
785
786    #[test]
787    fn security_summary_json_output_uses_security_root_contract() {
788        let output = SecurityOutput {
789            schema_version: SecuritySchemaVersion::V8,
790            version: ToolVersion("test".to_string()),
791            elapsed_ms: ElapsedMs(12),
792            config: json!({"rules": {}}),
793            meta: None,
794            gate: None::<()>,
795            security_findings: Vec::new(),
796            attack_surface: None,
797            unresolved_edge_files: 2,
798            unresolved_callee_sites: 3,
799            unresolved_callee_diagnostics: None,
800        };
801
802        let value = serialize_security_summary_json_output(&output, RootEnvelopeMode::Tagged, None)
803            .expect("security summary should serialize");
804
805        assert_eq!(value["kind"], "security");
806        assert_eq!(value["schema_version"], "8");
807        assert_eq!(value["summary"]["security_findings"], 0);
808        assert_eq!(value["summary"]["unresolved_edge_files"], 2);
809        assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
810        assert!(value.get("security_findings").is_none());
811    }
812
813    #[test]
814    fn saved_security_validator_accepts_current_full_and_summary_payloads() {
815        let full = current_security_envelope();
816        validate_saved_security_envelope(&full).expect("current full security envelope");
817
818        let mut summary = current_security_envelope();
819        summary
820            .as_object_mut()
821            .expect("security envelope")
822            .remove("security_findings");
823        summary
824            .as_object_mut()
825            .expect("security envelope")
826            .remove("unresolved_edge_files");
827        summary
828            .as_object_mut()
829            .expect("security envelope")
830            .remove("unresolved_callee_sites");
831        summary["summary"] = serde_json::to_value(SecuritySummary {
832            security_findings: 0,
833            by_severity: SecuritySeverityCounts::default(),
834            by_category: BTreeMap::new(),
835            by_reachability: SecurityReachabilityCounts::default(),
836            by_runtime_state: SecurityRuntimeStateCounts::default(),
837            unresolved_edge_files: 0,
838            unresolved_callee_sites: 0,
839            attack_surface_entries: 0,
840        })
841        .expect("security summary");
842        validate_saved_security_envelope(&summary).expect("current summary security envelope");
843    }
844
845    #[test]
846    fn saved_security_validator_rejects_malformed_current_payloads() {
847        let mut findings = current_security_envelope();
848        findings["security_findings"] = json!("not-an-array");
849        assert!(validate_saved_security_envelope(&findings).is_err());
850
851        let mut type_aware = current_security_envelope();
852        type_aware["_meta"] = json!({"type_aware": {"queries": "not-an-array"}});
853        assert!(validate_saved_security_envelope(&type_aware).is_err());
854    }
855
856    #[test]
857    fn saved_security_validator_accepts_legacy_and_rejects_future_schema() {
858        validate_saved_security_envelope(&json!({"schema_version": "7"}))
859            .expect("known legacy security schema");
860        let error = validate_saved_security_envelope(&json!({"schema_version": "9"}))
861            .expect_err("future security schema must fail closed");
862        assert!(error.contains("unsupported saved security schema version 9"));
863    }
864}