-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmod.rs
More file actions
332 lines (305 loc) · 11.4 KB
/
Copy pathmod.rs
File metadata and controls
332 lines (305 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//! Patch Bridge — CVE mitigation lifecycle for upstream vulnerabilities.
//!
//! Bridges the gap between CVE disclosure and upstream fix by providing:
//! - Multi-source CVE intelligence (OSV API for MVP, expandable to NVD/GHSA/VirusTotal)
//! - Reachability analysis (phantom dependency detection via import scanning)
//! - Three-way classification: Mitigable / Unmitigable / Informational
//! - Mitigation registry for lifecycle tracking (apply → monitor → retire)
//!
//! This module extends panic-attack with `bridge` subcommands. The core tool
//! remains standalone — PanLL panel and BoJ cartridge are optional integrations.
//!
//! Design document: docs/patch-bridge-design.md
pub mod classify;
pub mod intelligence;
pub mod lockfile;
pub mod reachability;
pub mod registry;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
// ============================================================================
// Core types
// ============================================================================
/// A known vulnerability from a CVE feed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vulnerability {
/// Advisory ID (e.g., "RUSTSEC-2023-0071", "GHSA-xxxx-xxxx")
pub id: String,
/// CVE ID if assigned (e.g., "CVE-2023-49092")
pub cve: Option<String>,
/// Human-readable summary
pub summary: String,
/// Affected package name
pub package: String,
/// Affected version in this project
pub version: String,
/// CVSS severity score (0.0–10.0)
pub severity: Option<f64>,
/// Severity label derived from CVSS
pub severity_label: SeverityLabel,
/// Fixed version(s) if available
pub fixed_versions: Vec<String>,
/// Whether a semver-compatible fix exists for the current pin
pub semver_fix_available: bool,
/// Source tier that reported this vulnerability
pub source_tier: SourceTier,
/// URL references
pub references: Vec<String>,
}
/// Severity label for display and triage.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SeverityLabel {
Critical,
High,
Medium,
Low,
Informational,
}
impl SeverityLabel {
/// Derive from CVSS score.
pub fn from_cvss(score: f64) -> Self {
if score >= 9.0 {
Self::Critical
} else if score >= 7.0 {
Self::High
} else if score >= 4.0 {
Self::Medium
} else {
Self::Low
}
}
}
/// Intelligence source tier (Ground News model for CVEs).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SourceTier {
/// Standard advisories: NVD, GHSA, OSV, vendor
Tier1,
/// Community intelligence: VirusTotal, ExploitDB, language-specific
Tier2,
/// Long tail: oss-security, academic, upstream commits
Tier3,
}
/// A dependency extracted from a lockfile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedDependency {
/// Package name
pub name: String,
/// Locked version
pub version: String,
/// Ecosystem (e.g., "crates.io", "npm", "hex")
pub ecosystem: String,
/// Which workspace members declare this dependency (Cargo.toml paths)
pub declared_by: Vec<PathBuf>,
}
/// Reachability evidence for a dependency in the codebase.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReachabilityEvidence {
/// Whether any source file imports this dependency
pub is_imported: bool,
/// Files that import the dependency, with line numbers
pub import_sites: Vec<ImportSite>,
/// Classification based on import analysis
pub status: ReachabilityStatus,
/// For `PhantomTransitive`: the direct dep (declared in Cargo.toml) whose
/// dependency closure pulls in this crate, best-effort. `None` if the
/// parent could not be identified or if status is not `PhantomTransitive`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_dep: Option<String>,
}
/// Where a dependency is imported in source code.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportSite {
/// File path relative to project root
pub file: PathBuf,
/// Line number of the import
pub line: usize,
/// The import statement text
pub statement: String,
}
/// Whether a dependency is reachable from application code.
///
/// `PhantomDeclared` and `PhantomTransitive` are both informational, but
/// distinguish the remediation path:
///
/// - **PhantomDeclared** — crate IS listed in the project's `Cargo.toml`
/// (root or workspace member) but no `use <crate>` site exists. Fix:
/// strip the manifest line (`cargo machete --fix` or manual removal).
/// - **PhantomTransitive** — crate is NOT in any Cargo.toml; it's pulled
/// in by a parent dep. Local stripping is impossible; the fix requires
/// bumping the parent dependency past the affected version.
///
/// JSON wire format uses `phantom-declared` / `phantom-transitive` /
/// `unreachable` / `reachable`. See `schema_version` 0.2.0.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReachabilityStatus {
/// Declared in Cargo.toml but never imported — the manifest entry is
/// genuinely unused. Strip the dep to eliminate the CVE entirely.
PhantomDeclared,
/// NOT declared in any Cargo.toml; pulled in transitively by a parent
/// crate. Local strip is impossible; fix the parent.
PhantomTransitive,
/// Imported but no data flow to vulnerable code path (Phase 2: kanren taint)
Unreachable,
/// Imported and potentially reachable
Reachable,
}
/// Three-way CVE classification result.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Classification {
/// A semver-compatible fix exists or a control can be layered
Mitigable,
/// No fix available and dependency is reachable
Unmitigable,
/// CVE exists but dependency is phantom/unreachable in this codebase
Informational,
}
/// A fully assessed CVE with classification and evidence.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssessedCve {
/// The vulnerability details
pub vulnerability: Vulnerability,
/// Reachability evidence
pub reachability: ReachabilityEvidence,
/// Final classification
pub classification: Classification,
/// Human-readable explanation of the classification
pub rationale: String,
/// Suggested action
pub action: String,
}
/// The complete Patch Bridge triage report.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeReport {
/// Schema version for forward compatibility
pub schema_version: String,
/// Project path that was assessed
pub project: PathBuf,
/// Total dependencies scanned
pub total_dependencies: usize,
/// Dependencies with known vulnerabilities
pub vulnerable_dependencies: usize,
/// All assessed CVEs with classifications
pub cves: Vec<AssessedCve>,
/// Count of mitigable CVEs
pub mitigated: usize,
/// Count of unmitigable CVEs
pub unmitigable: usize,
/// Count of concatenative risks (Phase 2)
pub concatenative: usize,
/// Count of informational CVEs (phantom/unreachable)
pub informational: usize,
}
impl BridgeReport {
/// Create an empty report for a project with no vulnerabilities.
pub fn empty(project: &Path, total_deps: usize) -> Self {
Self {
schema_version: "0.2.0".to_string(),
project: project.to_path_buf(),
total_dependencies: total_deps,
vulnerable_dependencies: 0,
cves: Vec::new(),
mitigated: 0,
unmitigable: 0,
concatenative: 0,
informational: 0,
}
}
/// Recompute counts from the CVE list.
pub fn recount(&mut self) {
self.mitigated = self
.cves
.iter()
.filter(|c| c.classification == Classification::Mitigable)
.count();
self.unmitigable = self
.cves
.iter()
.filter(|c| c.classification == Classification::Unmitigable)
.count();
self.informational = self
.cves
.iter()
.filter(|c| c.classification == Classification::Informational)
.count();
self.vulnerable_dependencies = self.cves.len();
}
}
// ============================================================================
// Top-level triage orchestrator
// ============================================================================
/// Run the full Patch Bridge triage on a project directory.
///
/// 1. Parse lockfile to extract dependencies
/// 2. Query OSV API for known vulnerabilities
/// 3. For each vulnerable dep, check reachability (import scanning)
/// 4. Classify: mitigable / unmitigable / informational
/// 5. Return structured report
pub fn triage(project_dir: &Path, offline: bool) -> anyhow::Result<BridgeReport> {
// Step 1: Discover and parse all lockfiles (Cargo.lock, mix.lock,
// package-lock.json, requirements.txt). At least one must exist.
let deps = lockfile::discover_and_parse(project_dir);
if deps.is_empty() {
anyhow::bail!(
"No supported lockfile found in {}. \
Supported: Cargo.lock (Rust), mix.lock (Elixir), \
package-lock.json (npm), requirements.txt (Python).",
project_dir.display()
);
}
let total_deps = deps.len();
if deps.is_empty() {
return Ok(BridgeReport::empty(project_dir, 0));
}
// Step 2: Query for vulnerabilities
let vulns = if offline {
Vec::new() // Offline mode: skip API, rely on local advisory DB
} else {
intelligence::query_osv_batch(&deps)?
};
if vulns.is_empty() {
return Ok(BridgeReport::empty(project_dir, total_deps));
}
// Direct-vs-transitive lookup table for the project's Cargo.toml.
// Collected once (not per-CVE) to avoid re-parsing the manifest for
// every vulnerable dep — see #47.
let direct_deps = lockfile::collect_direct_cargo_dependencies(project_dir);
// Parent-dep map from Cargo.lock: for any transitive crate, identify
// which direct dep's closure pulls it in (best-effort, first match wins).
let parent_map = lockfile::collect_cargo_parents(project_dir, &direct_deps);
// Step 3 & 4: For each vuln, check reachability and classify
let mut assessed = Vec::new();
for vuln in vulns {
let evidence = reachability::check_reachability_with_manifest(
project_dir,
&vuln.package,
&direct_deps,
&parent_map,
)?;
let (classification, rationale, action) = classify::classify(&vuln, &evidence);
assessed.push(AssessedCve {
vulnerability: vuln,
reachability: evidence,
classification,
rationale,
action,
});
}
let mut report = BridgeReport {
schema_version: "0.2.0".to_string(),
project: project_dir.to_path_buf(),
total_dependencies: total_deps,
vulnerable_dependencies: 0,
cves: assessed,
mitigated: 0,
unmitigable: 0,
concatenative: 0,
informational: 0,
};
report.recount();
Ok(report)
}