Skip to main content

chio_external_guards/external/threat_intel/
snyk.rs

1//! Snyk vulnerability-lookup adapter (phase 13.3).
2//!
3//! Adapted from
4//! `../clawdstrike/crates/libs/clawdstrike/src/async_guards/threat_intel/snyk.rs`,
5//! but reshaped to query a specific package + version rather than a
6//! manifest-level bulk test. The argument envelope is:
7//!
8//! ```json
9//! {"package": "lodash", "version": "4.17.20", "ecosystem": "npm"}
10//! ```
11//!
12//! Calls go to `{base_url}/test/{ecosystem}/{package}/{version}` (the
13//! Snyk v1 path for per-package lookups). The guard denies when any
14//! returned vulnerability has a severity at or above the configured
15//! threshold (and, optionally, is flagged upgradable).
16
17use 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
31/// Guard name reported by [`SnykGuard::name`].
32pub const GUARD_NAME: &str = "snyk";
33
34/// Default base URL.
35pub const DEFAULT_BASE_URL: &str = "https://snyk.io/api/v1";
36
37/// Default request timeout.
38pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
39
40/// Snyk severity levels.
41#[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/// Configuration for [`SnykGuard`].
62#[derive(Clone)]
63pub struct SnykConfig {
64    /// `Authorization: token <api_token>` credential.
65    pub api_token: Zeroizing<String>,
66    /// Snyk organization id.
67    pub org_id: String,
68    /// Override the base URL (test hook).
69    pub base_url: Option<String>,
70    /// Severity at or above which vulnerabilities trigger a deny.
71    pub severity_threshold: SnykSeverity,
72    /// When `true`, only deny if the Snyk record marks the vuln as
73    /// upgradable.
74    pub fail_on_upgradable_only: bool,
75    /// Per-request HTTP timeout.
76    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    /// Build a config with defaults.
94    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    /// Override the base URL (tests).
106    pub fn with_base_url(mut self, base: impl Into<String>) -> Self {
107        self.base_url = Some(base.into());
108        self
109    }
110
111    /// Override the severity threshold.
112    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/// Structured receipt evidence.
162#[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
179/// Guard wrapping Snyk per-package lookups.
180pub struct SnykGuard {
181    cfg: SnykConfig,
182    base_url: String,
183    http: Client,
184}
185
186impl SnykGuard {
187    /// Build a guard with an internally-owned [`reqwest::Client`].
188    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    /// Build with a caller-supplied client.
202    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    /// Build a [`GuardEvidence`] record for a prior decision.
212    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    // Small hand-rolled URL component encoder for the narrow set of chars
335    // we expect in ecosystem / package / version / org-id fields. Avoids
336    // a direct dep on the `urlencoding` crate.
337    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}