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