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,
60 pub verdict: SecurityGateVerdict,
61 pub new_count: usize,
63}
64
65#[derive(Debug, Clone, Serialize)]
67#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
68#[cfg_attr(
69 feature = "schema",
70 schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
71)]
72pub struct SecurityOutputConfig<Severity> {
73 pub rules: SecurityOutputRulesConfig<Severity>,
76 pub categories_include: Option<Vec<String>>,
79 pub categories_exclude: Option<Vec<String>>,
82}
83
84#[derive(Debug, Clone, Copy, Serialize)]
85#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86pub struct SecurityOutputRulesConfig<Severity> {
87 pub security_client_server_leak: SecurityRuleSeverityConfig<Severity>,
88 pub security_sink: SecurityRuleSeverityConfig<Severity>,
89}
90
91#[derive(Debug, Clone, Copy, Serialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub struct SecurityRuleSeverityConfig<Severity> {
94 pub configured: Severity,
97 pub effective: Severity,
99}
100
101#[derive(Debug, Clone, Serialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct SecurityOutput<Config, Gate> {
107 pub schema_version: SecuritySchemaVersion,
109 pub version: ToolVersion,
111 pub elapsed_ms: ElapsedMs,
113 pub config: Config,
115 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
117 pub meta: Option<Meta>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub gate: Option<Gate>,
123 pub security_findings: Vec<SecurityFinding>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
129 pub unresolved_edge_files: usize,
134 pub unresolved_callee_sites: usize,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
142}
143
144#[derive(Debug, Clone, Serialize)]
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147pub struct SecurityUnresolvedCalleeDiagnostics {
148 pub sampled: Vec<SecurityUnresolvedCalleeSample>,
150 pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
152 pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
154 pub sample_limit: usize,
156 pub top_files_limit: usize,
158}
159
160#[derive(Debug, Clone, Serialize)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163pub struct SecurityUnresolvedCalleeSample {
164 pub path: String,
165 pub line: u32,
166 pub col: u32,
167 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
168 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
170}
171
172#[derive(Debug, Clone, Serialize)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175pub struct SecurityUnresolvedCalleeTopFile {
176 pub path: String,
177 pub count: usize,
179}
180
181#[derive(Debug, Clone, Serialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184pub struct SecurityUnresolvedCalleeReasonCount {
185 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
186 pub count: usize,
188}
189
190#[derive(Debug, Clone, Serialize)]
194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
195pub struct SecuritySummaryOutput<Config, Gate> {
196 pub schema_version: SecuritySchemaVersion,
198 pub version: ToolVersion,
200 pub elapsed_ms: ElapsedMs,
202 pub config: Config,
204 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
206 pub meta: Option<Meta>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub gate: Option<Gate>,
210 pub summary: SecuritySummary,
212}
213
214#[must_use]
216pub fn build_security_summary<Config, Gate>(
217 output: &SecurityOutput<Config, Gate>,
218) -> SecuritySummary {
219 let mut counts = SecuritySummaryCounts::default();
220
221 for finding in &output.security_findings {
222 counts.record(finding);
223 }
224
225 SecuritySummary {
226 security_findings: output.security_findings.len(),
227 by_severity: counts.severity,
228 by_category: counts.category,
229 by_reachability: counts.reachability,
230 by_runtime_state: counts.runtime_state,
231 unresolved_edge_files: output.unresolved_edge_files,
232 unresolved_callee_sites: output.unresolved_callee_sites,
233 attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
234 }
235}
236
237#[derive(Default)]
238struct SecuritySummaryCounts {
239 severity: SecuritySeverityCounts,
240 category: BTreeMap<String, usize>,
241 reachability: SecurityReachabilityCounts,
242 runtime_state: SecurityRuntimeStateCounts,
243}
244
245impl SecuritySummaryCounts {
246 fn record(&mut self, finding: &SecurityFinding) {
247 record_security_severity(finding.severity, &mut self.severity);
248 record_security_category(finding, &mut self.category);
249 record_security_reachability(finding, &mut self.reachability);
250 record_security_runtime_state(finding, &mut self.runtime_state);
251 }
252}
253
254fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
255 match severity {
256 SecuritySeverity::High => by_severity.high += 1,
257 SecuritySeverity::Medium => by_severity.medium += 1,
258 SecuritySeverity::Low => by_severity.low += 1,
259 }
260}
261
262fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
263 let category = finding
264 .category
265 .clone()
266 .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
267 *by_category.entry(category).or_insert(0) += 1;
268}
269
270fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
271 match kind {
272 SecurityFindingKind::ClientServerLeak => "client-server-leak",
273 SecurityFindingKind::TaintedSink => "tainted-sink",
274 }
275}
276
277fn record_security_reachability(
278 finding: &SecurityFinding,
279 by_reachability: &mut SecurityReachabilityCounts,
280) {
281 if finding.source_backed {
282 by_reachability.source_backed += 1;
283 }
284 let Some(reachability) = &finding.reachability else {
285 return;
286 };
287
288 if reachability.reachable_from_entry {
289 by_reachability.entry_reachable += 1;
290 }
291 if reachability.reachable_from_untrusted_source {
292 by_reachability.untrusted_source_reachable += 1;
293 }
294 if reachability.crosses_boundary {
295 by_reachability.crosses_boundary += 1;
296 }
297 match reachability.taint_confidence {
298 Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
299 Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
300 None => {}
301 }
302}
303
304fn record_security_runtime_state(
305 finding: &SecurityFinding,
306 by_runtime_state: &mut SecurityRuntimeStateCounts,
307) {
308 match finding.runtime.as_ref().map(|runtime| runtime.state) {
309 Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
310 Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
311 Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
312 Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
313 Some(SecurityRuntimeState::CoverageUnavailable) => {
314 by_runtime_state.coverage_unavailable += 1;
315 }
316 Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
317 None => by_runtime_state.not_collected += 1,
318 }
319}
320
321pub fn serialize_security_json_output<Config, Gate>(
327 output: SecurityOutput<Config, Gate>,
328 mode: RootEnvelopeMode,
329 analysis_run_id: Option<&str>,
330) -> Result<serde_json::Value, serde_json::Error>
331where
332 Config: Serialize,
333 Gate: Serialize,
334{
335 let mut value = serialize_named_json_output(output, "security", mode)?;
336 attach_telemetry_meta(&mut value, analysis_run_id);
337 Ok(value)
338}
339
340pub fn serialize_security_summary_json_output<Config, Gate>(
346 output: &SecurityOutput<Config, Gate>,
347 mode: RootEnvelopeMode,
348 analysis_run_id: Option<&str>,
349) -> Result<serde_json::Value, serde_json::Error>
350where
351 Config: Clone + Serialize,
352 Gate: Copy + Serialize,
353{
354 let summary = SecuritySummaryOutput {
355 schema_version: output.schema_version,
356 version: output.version.clone(),
357 elapsed_ms: output.elapsed_ms,
358 config: output.config.clone(),
359 meta: output.meta.clone(),
360 gate: output.gate,
361 summary: build_security_summary(output),
362 };
363 let mut value = serialize_named_json_output(summary, "security", mode)?;
364 attach_telemetry_meta(&mut value, analysis_run_id);
365 Ok(value)
366}
367
368pub fn serialize_security_survivors_json_output(
374 output: SecuritySurvivorsOutput,
375 mode: RootEnvelopeMode,
376) -> Result<serde_json::Value, serde_json::Error> {
377 serialize_named_json_output(output, "security-survivors", mode)
378}
379
380pub fn serialize_security_blind_spots_json_output(
386 output: SecurityBlindSpotsOutput,
387 mode: RootEnvelopeMode,
388) -> Result<serde_json::Value, serde_json::Error> {
389 serialize_named_json_output(output, "security-blind-spots", mode)
390}
391
392#[derive(Debug, Clone, Serialize)]
394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
395pub struct SecuritySummary {
396 pub security_findings: usize,
398 pub by_severity: SecuritySeverityCounts,
400 pub by_category: BTreeMap<String, usize>,
403 pub by_reachability: SecurityReachabilityCounts,
405 pub by_runtime_state: SecurityRuntimeStateCounts,
407 pub unresolved_edge_files: usize,
409 pub unresolved_callee_sites: usize,
411 pub attack_surface_entries: usize,
413}
414
415#[derive(Debug, Clone, Copy, Default, Serialize)]
417#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
418pub struct SecuritySeverityCounts {
419 pub high: usize,
420 pub medium: usize,
421 pub low: usize,
422}
423
424#[derive(Debug, Clone, Copy, Default, Serialize)]
426#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
427pub struct SecurityReachabilityCounts {
428 pub entry_reachable: usize,
429 pub untrusted_source_reachable: usize,
430 pub arg_level: usize,
431 pub module_level: usize,
432 pub crosses_boundary: usize,
433 pub source_backed: usize,
434}
435
436#[derive(Debug, Clone, Copy, Default, Serialize)]
438#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
439pub struct SecurityRuntimeStateCounts {
440 pub runtime_hot: usize,
441 pub runtime_cold: usize,
442 pub never_executed: usize,
443 pub low_traffic: usize,
444 pub coverage_unavailable: usize,
445 pub runtime_unknown: usize,
446 pub not_collected: usize,
447}
448
449#[derive(Debug, Clone, Copy, Serialize)]
451#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
452pub enum SecuritySurvivorsSchemaVersion {
453 #[serde(rename = "2")]
455 V2,
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
460#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
461#[serde(rename_all = "kebab-case")]
462pub enum SecurityVerifierVerdictStatus {
463 Survivor,
465 Dismissed,
467 NeedsHumanReview,
469}
470
471#[derive(Debug, Clone, Deserialize, Serialize)]
473#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
474pub struct SecurityVerifierVerdict {
475 pub schema_version: String,
477 pub finding_id: String,
479 pub verdict: SecurityVerifierVerdictStatus,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub reason: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub rationale: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub confidence: Option<String>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub impact: Option<String>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub fix_direction: Option<String>,
493}
494
495#[derive(Debug, Clone, Serialize)]
497#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
498pub struct SecuritySurvivorsOutput {
499 pub schema_version: SecuritySurvivorsSchemaVersion,
501 pub version: ToolVersion,
503 pub elapsed_ms: ElapsedMs,
505 pub summary: SecuritySurvivorsSummary,
506 pub survivors: BTreeMap<String, SecuritySurvivor>,
508 pub needs_human_review: BTreeMap<String, SecuritySurvivor>,
511}
512
513#[derive(Debug, Clone, Copy, Serialize)]
515#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
516pub struct SecuritySurvivorsSummary {
517 pub candidates: usize,
518 pub verdicts: usize,
519 pub survivors: usize,
520 pub dismissed: usize,
521 pub needs_human_review: usize,
522 pub unverdicted: usize,
523}
524
525#[derive(Debug, Clone, Serialize)]
527#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
528pub struct SecuritySurvivor {
529 pub finding_id: String,
531 pub verdict: SecurityVerifierVerdictStatus,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub reason: Option<String>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub rationale: Option<String>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub confidence: Option<String>,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub impact: Option<String>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub fix_direction: Option<String>,
545 pub candidate: SecurityFinding,
547}
548
549#[derive(Debug, Clone, Copy, Serialize)]
551#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
552pub enum SecurityBlindSpotsSchemaVersion {
553 #[serde(rename = "1")]
555 V1,
556}
557
558#[derive(Debug, Clone, Serialize)]
560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
561pub struct SecurityBlindSpotsOutput {
562 pub schema_version: SecurityBlindSpotsSchemaVersion,
564 pub version: ToolVersion,
566 pub elapsed_ms: ElapsedMs,
568 pub summary: SecurityBlindSpotsSummary,
570 pub groups: Vec<SecurityBlindSpotGroup>,
572}
573
574#[derive(Debug, Clone, Copy, Serialize)]
576#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
577pub struct SecurityBlindSpotsSummary {
578 pub unresolved_edge_files: usize,
579 pub unresolved_callee_sites: usize,
580 pub sampled_callee_sites: usize,
581}
582
583#[derive(Debug, Clone, Serialize)]
585#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
586pub struct SecurityBlindSpotGroup {
587 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
588 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
590 pub sampled_count: usize,
592 pub files: Vec<SecurityBlindSpotFile>,
594 pub suggestion: String,
596}
597
598#[derive(Debug, Clone, Serialize)]
600#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
601pub struct SecurityBlindSpotFile {
602 pub path: String,
603 pub sampled_count: usize,
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use serde_json::json;
611
612 #[test]
613 fn security_summary_json_output_uses_security_root_contract() {
614 let output = SecurityOutput {
615 schema_version: SecuritySchemaVersion::V7,
616 version: ToolVersion("test".to_string()),
617 elapsed_ms: ElapsedMs(12),
618 config: json!({"rules": {}}),
619 meta: None,
620 gate: None::<()>,
621 security_findings: Vec::new(),
622 attack_surface: None,
623 unresolved_edge_files: 2,
624 unresolved_callee_sites: 3,
625 unresolved_callee_diagnostics: None,
626 };
627
628 let value = serialize_security_summary_json_output(&output, RootEnvelopeMode::Tagged, None)
629 .expect("security summary should serialize");
630
631 assert_eq!(value["kind"], "security");
632 assert_eq!(value["schema_version"], "7");
633 assert_eq!(value["summary"]["security_findings"], 0);
634 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
635 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
636 assert!(value.get("security_findings").is_none());
637 }
638}