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