Skip to main content

fallow_output/
security.rs

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