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