1use std::collections::BTreeMap;
4
5use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
6use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
7use fallow_types::results::{
8 SecurityAttackSurfaceEntry, SecurityFinding, SecurityFindingKind, SecurityRuntimeState,
9 SecuritySeverity, TaintConfidence,
10};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, Serialize)]
16#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
17pub enum SecuritySchemaVersion {
18 #[serde(rename = "1")]
20 V1,
21 #[serde(rename = "2")]
23 V2,
24 #[serde(rename = "3")]
26 V3,
27 #[serde(rename = "4")]
29 V4,
30 #[serde(rename = "5")]
32 V5,
33 #[serde(rename = "6")]
35 V6,
36 #[serde(rename = "7")]
38 V7,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[serde(rename_all = "kebab-case")]
47pub enum SecurityGateVerdict {
48 Pass,
50 Fail,
52}
53
54#[derive(Debug, Clone, Copy, Serialize)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct SecurityGate<Mode> {
59 pub mode: Mode,
61 pub verdict: SecurityGateVerdict,
63 pub new_count: usize,
65}
66
67#[derive(Debug, Clone, Serialize)]
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
70#[cfg_attr(
71 feature = "schema",
72 schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
73)]
74pub struct SecurityOutputConfig<Severity> {
75 pub rules: SecurityOutputRulesConfig<Severity>,
78 pub categories_include: Option<Vec<String>>,
81 pub categories_exclude: Option<Vec<String>>,
84}
85
86#[derive(Debug, Clone, Copy, Serialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct SecurityOutputRulesConfig<Severity> {
90 pub security_client_server_leak: SecurityRuleSeverityConfig<Severity>,
92 pub security_sink: SecurityRuleSeverityConfig<Severity>,
94}
95
96#[derive(Debug, Clone, Copy, Serialize)]
98#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
99pub struct SecurityRuleSeverityConfig<Severity> {
100 pub configured: Severity,
103 pub effective: Severity,
105}
106
107#[derive(Debug, Clone, Serialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct SecurityOutput<Config, Gate> {
113 pub schema_version: SecuritySchemaVersion,
115 pub version: ToolVersion,
117 pub elapsed_ms: ElapsedMs,
119 pub config: Config,
121 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
123 pub meta: Option<Meta>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub gate: Option<Gate>,
129 pub security_findings: Vec<SecurityFinding>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
135 pub unresolved_edge_files: usize,
140 pub unresolved_callee_sites: usize,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
148}
149
150#[derive(Debug, Clone, Serialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153pub struct SecurityUnresolvedCalleeDiagnostics {
154 pub sampled: Vec<SecurityUnresolvedCalleeSample>,
156 pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
158 pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
160 pub sample_limit: usize,
162 pub top_files_limit: usize,
164}
165
166#[derive(Debug, Clone, Serialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169pub struct SecurityUnresolvedCalleeSample {
170 pub path: String,
172 pub line: u32,
174 pub col: u32,
176 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
178 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
180}
181
182#[derive(Debug, Clone, Serialize)]
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185pub struct SecurityUnresolvedCalleeTopFile {
186 pub path: String,
188 pub count: usize,
190}
191
192#[derive(Debug, Clone, Serialize)]
194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
195pub struct SecurityUnresolvedCalleeReasonCount {
196 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
198 pub count: usize,
200}
201
202#[derive(Debug, Clone, Serialize)]
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207pub struct SecuritySummaryOutput<Config, Gate> {
208 pub schema_version: SecuritySchemaVersion,
210 pub version: ToolVersion,
212 pub elapsed_ms: ElapsedMs,
214 pub config: Config,
216 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
218 pub meta: Option<Meta>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub gate: Option<Gate>,
222 pub summary: SecuritySummary,
224}
225
226#[must_use]
228pub fn build_security_summary<Config, Gate>(
229 output: &SecurityOutput<Config, Gate>,
230) -> SecuritySummary {
231 let mut counts = SecuritySummaryCounts::default();
232
233 for finding in &output.security_findings {
234 counts.record(finding);
235 }
236
237 SecuritySummary {
238 security_findings: output.security_findings.len(),
239 by_severity: counts.severity,
240 by_category: counts.category,
241 by_reachability: counts.reachability,
242 by_runtime_state: counts.runtime_state,
243 unresolved_edge_files: output.unresolved_edge_files,
244 unresolved_callee_sites: output.unresolved_callee_sites,
245 attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
246 }
247}
248
249#[derive(Default)]
250struct SecuritySummaryCounts {
251 severity: SecuritySeverityCounts,
252 category: BTreeMap<String, usize>,
253 reachability: SecurityReachabilityCounts,
254 runtime_state: SecurityRuntimeStateCounts,
255}
256
257impl SecuritySummaryCounts {
258 fn record(&mut self, finding: &SecurityFinding) {
259 record_security_severity(finding.severity, &mut self.severity);
260 record_security_category(finding, &mut self.category);
261 record_security_reachability(finding, &mut self.reachability);
262 record_security_runtime_state(finding, &mut self.runtime_state);
263 }
264}
265
266fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
267 match severity {
268 SecuritySeverity::High => by_severity.high += 1,
269 SecuritySeverity::Medium => by_severity.medium += 1,
270 SecuritySeverity::Low => by_severity.low += 1,
271 }
272}
273
274fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
275 let category = finding
276 .category
277 .clone()
278 .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
279 *by_category.entry(category).or_insert(0) += 1;
280}
281
282fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
283 match kind {
284 SecurityFindingKind::ClientServerLeak => "client-server-leak",
285 SecurityFindingKind::TaintedSink => "tainted-sink",
286 }
287}
288
289fn record_security_reachability(
290 finding: &SecurityFinding,
291 by_reachability: &mut SecurityReachabilityCounts,
292) {
293 if finding.source_backed {
294 by_reachability.source_backed += 1;
295 }
296 let Some(reachability) = &finding.reachability else {
297 return;
298 };
299
300 if reachability.reachable_from_entry {
301 by_reachability.entry_reachable += 1;
302 }
303 if reachability.reachable_from_untrusted_source {
304 by_reachability.untrusted_source_reachable += 1;
305 }
306 if reachability.crosses_boundary {
307 by_reachability.crosses_boundary += 1;
308 }
309 match reachability.taint_confidence {
310 Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
311 Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
312 None => {}
313 }
314}
315
316fn record_security_runtime_state(
317 finding: &SecurityFinding,
318 by_runtime_state: &mut SecurityRuntimeStateCounts,
319) {
320 match finding.runtime.as_ref().map(|runtime| runtime.state) {
321 Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
322 Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
323 Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
324 Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
325 Some(SecurityRuntimeState::CoverageUnavailable) => {
326 by_runtime_state.coverage_unavailable += 1;
327 }
328 Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
329 None => by_runtime_state.not_collected += 1,
330 }
331}
332
333pub fn serialize_security_json_output<Config, Gate>(
339 output: SecurityOutput<Config, Gate>,
340 mode: RootEnvelopeMode,
341 analysis_run_id: Option<&str>,
342) -> Result<serde_json::Value, serde_json::Error>
343where
344 Config: Serialize,
345 Gate: Serialize,
346{
347 let mut value = serialize_named_json_output(output, "security", mode)?;
348 attach_telemetry_meta(&mut value, analysis_run_id);
349 Ok(value)
350}
351
352pub fn serialize_security_summary_json_output<Config, Gate>(
358 output: &SecurityOutput<Config, Gate>,
359 mode: RootEnvelopeMode,
360 analysis_run_id: Option<&str>,
361) -> Result<serde_json::Value, serde_json::Error>
362where
363 Config: Clone + Serialize,
364 Gate: Copy + Serialize,
365{
366 let summary = SecuritySummaryOutput {
367 schema_version: output.schema_version,
368 version: output.version.clone(),
369 elapsed_ms: output.elapsed_ms,
370 config: output.config.clone(),
371 meta: output.meta.clone(),
372 gate: output.gate,
373 summary: build_security_summary(output),
374 };
375 let mut value = serialize_named_json_output(summary, "security", mode)?;
376 attach_telemetry_meta(&mut value, analysis_run_id);
377 Ok(value)
378}
379
380pub fn serialize_security_survivors_json_output(
386 output: SecuritySurvivorsOutput,
387 mode: RootEnvelopeMode,
388) -> Result<serde_json::Value, serde_json::Error> {
389 serialize_named_json_output(output, "security-survivors", mode)
390}
391
392pub fn serialize_security_blind_spots_json_output(
398 output: SecurityBlindSpotsOutput,
399 mode: RootEnvelopeMode,
400) -> Result<serde_json::Value, serde_json::Error> {
401 serialize_named_json_output(output, "security-blind-spots", mode)
402}
403
404#[derive(Debug, Clone, Serialize)]
406#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
407pub struct SecuritySummary {
408 pub security_findings: usize,
410 pub by_severity: SecuritySeverityCounts,
412 pub by_category: BTreeMap<String, usize>,
415 pub by_reachability: SecurityReachabilityCounts,
417 pub by_runtime_state: SecurityRuntimeStateCounts,
419 pub unresolved_edge_files: usize,
421 pub unresolved_callee_sites: usize,
423 pub attack_surface_entries: usize,
425}
426
427#[derive(Debug, Clone, Copy, Default, Serialize)]
429#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
430pub struct SecuritySeverityCounts {
431 pub high: usize,
433 pub medium: usize,
435 pub low: usize,
437}
438
439#[derive(Debug, Clone, Copy, Default, Serialize)]
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
442pub struct SecurityReachabilityCounts {
443 pub entry_reachable: usize,
445 pub untrusted_source_reachable: usize,
447 pub arg_level: usize,
449 pub module_level: usize,
451 pub crosses_boundary: usize,
453 pub source_backed: usize,
455}
456
457#[derive(Debug, Clone, Copy, Default, Serialize)]
459#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
460pub struct SecurityRuntimeStateCounts {
461 pub runtime_hot: usize,
463 pub runtime_cold: usize,
465 pub never_executed: usize,
467 pub low_traffic: usize,
469 pub coverage_unavailable: usize,
471 pub runtime_unknown: usize,
473 pub not_collected: usize,
475}
476
477#[derive(Debug, Clone, Copy, Serialize)]
479#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
480pub enum SecuritySurvivorsSchemaVersion {
481 #[serde(rename = "2")]
483 V2,
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
488#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
489#[serde(rename_all = "kebab-case")]
490pub enum SecurityVerifierVerdictStatus {
491 Survivor,
493 Dismissed,
495 NeedsHumanReview,
497}
498
499#[derive(Debug, Clone, Deserialize, Serialize)]
501#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
502pub struct SecurityVerifierVerdict {
503 pub schema_version: String,
505 pub finding_id: String,
507 pub verdict: SecurityVerifierVerdictStatus,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub reason: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub rationale: Option<String>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub confidence: Option<String>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub impact: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub fix_direction: Option<String>,
524}
525
526#[derive(Debug, Clone, Serialize)]
528#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
529pub struct SecuritySurvivorsOutput {
530 pub schema_version: SecuritySurvivorsSchemaVersion,
532 pub version: ToolVersion,
534 pub elapsed_ms: ElapsedMs,
536 pub summary: SecuritySurvivorsSummary,
538 pub survivors: BTreeMap<String, SecuritySurvivor>,
540 pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
543}
544
545#[derive(Debug, Clone, Copy, Serialize)]
547#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
548pub struct SecuritySurvivorsSummary {
549 pub candidates: usize,
551 pub verdicts: usize,
553 pub survivors: usize,
555 pub dismissed: usize,
557 pub needs_human_review: usize,
559 pub unverdicted: usize,
561}
562
563#[derive(Debug, Clone, Serialize)]
565#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
566pub struct SecuritySurvivor {
567 pub finding_id: String,
569 pub verdict: SecurityVerifierVerdictStatus,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub reason: Option<String>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub rationale: Option<String>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub confidence: Option<String>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub impact: Option<String>,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub fix_direction: Option<String>,
586 pub candidate: SecurityFinding,
588}
589
590#[derive(Debug, Clone, Copy, Serialize)]
592#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
593pub enum SecurityBlindSpotsSchemaVersion {
594 #[serde(rename = "1")]
596 V1,
597}
598
599#[derive(Debug, Clone, Serialize)]
601#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
602pub struct SecurityBlindSpotsOutput {
603 pub schema_version: SecurityBlindSpotsSchemaVersion,
605 pub version: ToolVersion,
607 pub elapsed_ms: ElapsedMs,
609 pub summary: SecurityBlindSpotsSummary,
611 pub groups: Vec<SecurityBlindSpotGroup>,
613}
614
615#[derive(Debug, Clone, Copy, Serialize)]
617#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
618pub struct SecurityBlindSpotsSummary {
619 pub unresolved_edge_files: usize,
621 pub unresolved_callee_sites: usize,
623 pub sampled_callee_sites: usize,
625}
626
627#[derive(Debug, Clone, Serialize)]
629#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
630pub struct SecurityBlindSpotGroup {
631 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
633 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
635 pub sampled_count: usize,
637 pub files: Vec<SecurityBlindSpotFile>,
639 pub suggestion: String,
641}
642
643#[derive(Debug, Clone, Serialize)]
645#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
646pub struct SecurityBlindSpotFile {
647 pub path: String,
649 pub sampled_count: usize,
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use serde_json::json;
657
658 #[test]
659 fn security_summary_json_output_uses_security_root_contract() {
660 let output = SecurityOutput {
661 schema_version: SecuritySchemaVersion::V7,
662 version: ToolVersion("test".to_string()),
663 elapsed_ms: ElapsedMs(12),
664 config: json!({"rules": {}}),
665 meta: None,
666 gate: None::<()>,
667 security_findings: Vec::new(),
668 attack_surface: None,
669 unresolved_edge_files: 2,
670 unresolved_callee_sites: 3,
671 unresolved_callee_diagnostics: None,
672 };
673
674 let value = serialize_security_summary_json_output(&output, RootEnvelopeMode::Tagged, None)
675 .expect("security summary should serialize");
676
677 assert_eq!(value["kind"], "security");
678 assert_eq!(value["schema_version"], "7");
679 assert_eq!(value["summary"]["security_findings"], 0);
680 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
681 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
682 assert!(value.get("security_findings").is_none());
683 }
684}