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