chio_external_guards/external/threat_intel/
snyk.rs1use std::time::Duration;
18
19use async_trait::async_trait;
20use chio_core_types::GuardEvidence;
21use chio_kernel::Verdict;
22use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
23use reqwest::Client;
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26use zeroize::Zeroizing;
27
28use crate::external::bedrock::{classify_reqwest_error, classify_status_error};
29use crate::external::{ExternalGuard, ExternalGuardError, GuardCallContext};
30
31pub const GUARD_NAME: &str = "snyk";
33
34pub const DEFAULT_BASE_URL: &str = "https://snyk.io/api/v1";
36
37pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
42#[serde(rename_all = "lowercase")]
43pub enum SnykSeverity {
44 Low,
45 Medium,
46 High,
47 Critical,
48}
49
50impl SnykSeverity {
51 fn rank(self) -> u8 {
52 match self {
53 Self::Low => 0,
54 Self::Medium => 1,
55 Self::High => 2,
56 Self::Critical => 3,
57 }
58 }
59}
60
61#[derive(Clone)]
63pub struct SnykConfig {
64 pub api_token: Zeroizing<String>,
66 pub org_id: String,
68 pub base_url: Option<String>,
70 pub severity_threshold: SnykSeverity,
72 pub fail_on_upgradable_only: bool,
75 pub timeout: Duration,
77}
78
79impl std::fmt::Debug for SnykConfig {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("SnykConfig")
82 .field("api_token", &"***redacted***")
83 .field("org_id", &self.org_id)
84 .field("base_url", &self.base_url)
85 .field("severity_threshold", &self.severity_threshold)
86 .field("fail_on_upgradable_only", &self.fail_on_upgradable_only)
87 .field("timeout", &self.timeout)
88 .finish()
89 }
90}
91
92impl SnykConfig {
93 pub fn new(api_token: impl Into<String>, org_id: impl Into<String>) -> Self {
95 Self {
96 api_token: Zeroizing::new(api_token.into()),
97 org_id: org_id.into(),
98 base_url: None,
99 severity_threshold: SnykSeverity::High,
100 fail_on_upgradable_only: false,
101 timeout: DEFAULT_TIMEOUT,
102 }
103 }
104
105 pub fn with_base_url(mut self, base: impl Into<String>) -> Self {
107 self.base_url = Some(base.into());
108 self
109 }
110
111 pub fn with_severity_threshold(mut self, threshold: SnykSeverity) -> Self {
113 self.severity_threshold = threshold;
114 self
115 }
116
117 fn resolved_base_url(&self) -> String {
118 self.base_url
119 .clone()
120 .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
121 .trim_end_matches('/')
122 .to_string()
123 }
124}
125
126#[derive(Debug, Clone, Deserialize)]
127struct SnykArgs {
128 package: String,
129 version: String,
130 ecosystem: String,
131}
132
133#[derive(Debug, Clone, Deserialize)]
134struct SnykResponse {
135 #[serde(default)]
136 issues: Option<SnykIssues>,
137 #[serde(default)]
138 vulnerabilities: Vec<SnykVuln>,
139}
140
141#[derive(Debug, Clone, Deserialize)]
142struct SnykIssues {
143 #[serde(default)]
144 vulnerabilities: Vec<SnykVuln>,
145}
146
147#[derive(Debug, Clone, Deserialize)]
148struct SnykVuln {
149 #[serde(default)]
150 severity: Option<SnykSeverity>,
151 #[serde(default, rename = "isUpgradable")]
152 is_upgradable: Option<bool>,
153 #[serde(default)]
154 #[allow(dead_code)]
155 id: Option<String>,
156 #[serde(default)]
157 #[allow(dead_code)]
158 title: Option<String>,
159}
160
161#[derive(Debug, Clone, Serialize)]
163pub struct SnykEvidence {
164 pub package: String,
165 pub version: String,
166 pub ecosystem: String,
167 pub threshold: SnykSeverity,
168 pub vulns_at_or_above: Vec<SnykVulnSummary>,
169}
170
171#[derive(Debug, Clone, Serialize)]
172pub struct SnykVulnSummary {
173 pub id: Option<String>,
174 pub title: Option<String>,
175 pub severity: SnykSeverity,
176 pub upgradable: bool,
177}
178
179pub struct SnykGuard {
181 cfg: SnykConfig,
182 base_url: String,
183 http: Client,
184}
185
186impl SnykGuard {
187 pub fn new(cfg: SnykConfig) -> Result<Self, ExternalGuardError> {
189 let http = Client::builder()
190 .timeout(cfg.timeout)
191 .build()
192 .map_err(|e| ExternalGuardError::Permanent(format!("reqwest build: {e}")))?;
193 let base_url = cfg.resolved_base_url();
194 Ok(Self {
195 cfg,
196 base_url,
197 http,
198 })
199 }
200
201 pub fn with_client(cfg: SnykConfig, http: Client) -> Self {
203 let base_url = cfg.resolved_base_url();
204 Self {
205 cfg,
206 base_url,
207 http,
208 }
209 }
210
211 pub fn evidence_from_decision(
213 &self,
214 verdict: Verdict,
215 details: Option<&SnykEvidence>,
216 ) -> GuardEvidence {
217 GuardEvidence {
218 guard_name: self.name().to_string(),
219 verdict: matches!(verdict, Verdict::Allow),
220 details: details.and_then(|d| serde_json::to_string(d).ok()),
221 }
222 }
223}
224
225#[async_trait]
226impl ExternalGuard for SnykGuard {
227 fn name(&self) -> &str {
228 GUARD_NAME
229 }
230
231 fn cache_key(&self, ctx: &GuardCallContext) -> Option<String> {
232 let args: SnykArgs = serde_json::from_str(&ctx.arguments_json).ok()?;
233 let mut hasher = Sha256::new();
234 hasher.update(args.ecosystem.as_bytes());
235 hasher.update(b":");
236 hasher.update(args.package.as_bytes());
237 hasher.update(b":");
238 hasher.update(args.version.as_bytes());
239 let digest = hasher.finalize();
240 let mut hex = String::with_capacity(digest.len() * 2);
241 for b in digest {
242 hex.push_str(&format!("{b:02x}"));
243 }
244 Some(format!("snyk:{hex}"))
245 }
246
247 async fn eval(&self, ctx: &GuardCallContext) -> Result<Verdict, ExternalGuardError> {
248 let args: SnykArgs = serde_json::from_str(&ctx.arguments_json)
249 .map_err(|e| ExternalGuardError::Permanent(format!("invalid snyk arguments: {e}")))?;
250
251 let endpoint = format!(
252 "{}/test/{}/{}/{}?orgId={}",
253 self.base_url,
254 url_encode(&args.ecosystem),
255 url_encode(&args.package),
256 url_encode(&args.version),
257 url_encode(&self.cfg.org_id),
258 );
259 super::super::endpoint_security::validate_external_guard_url("snyk base_url", &endpoint)?;
260
261 let mut headers = HeaderMap::new();
262 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
263 let auth_value = format!("token {}", self.cfg.api_token.as_str());
264 headers.insert(
265 AUTHORIZATION,
266 HeaderValue::from_str(&auth_value)
267 .map_err(|e| ExternalGuardError::Permanent(format!("invalid api token: {e}")))?,
268 );
269
270 let resp = self
271 .http
272 .get(&endpoint)
273 .headers(headers)
274 .send()
275 .await
276 .map_err(classify_reqwest_error)?;
277
278 let status = resp.status();
279 let text = resp
280 .text()
281 .await
282 .map_err(|e| ExternalGuardError::Transient(format!("read body: {e}")))?;
283
284 if !status.is_success() {
285 return Err(classify_status_error("snyk", status, &text));
286 }
287
288 let parsed: SnykResponse = serde_json::from_str(&text)
289 .map_err(|e| ExternalGuardError::Transient(format!("parse snyk response: {e}")))?;
290
291 let mut vulns: Vec<&SnykVuln> = parsed.vulnerabilities.iter().collect();
292 if let Some(issues) = parsed.issues.as_ref() {
293 vulns.extend(issues.vulnerabilities.iter());
294 }
295
296 let threshold = self.cfg.severity_threshold.rank();
297 let mut denied = false;
298 let mut count_at_or_above = 0_usize;
299 for v in vulns {
300 let Some(sev) = v.severity else {
301 continue;
302 };
303 if sev.rank() < threshold {
304 continue;
305 }
306 count_at_or_above += 1;
307 let upgradable = v.is_upgradable.unwrap_or(false);
308 if self.cfg.fail_on_upgradable_only {
309 if upgradable {
310 denied = true;
311 }
312 } else {
313 denied = true;
314 }
315 }
316
317 tracing::info!(
318 guard = GUARD_NAME,
319 count_at_or_above,
320 upgradable_only = self.cfg.fail_on_upgradable_only,
321 denied,
322 "snyk response"
323 );
324
325 Ok(if denied {
326 Verdict::Deny
327 } else {
328 Verdict::Allow
329 })
330 }
331}
332
333fn url_encode(input: &str) -> String {
334 let mut out = String::with_capacity(input.len());
338 for b in input.bytes() {
339 match b {
340 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
341 out.push(b as char);
342 }
343 _ => out.push_str(&format!("%{:02X}", b)),
344 }
345 }
346 out
347}