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