Skip to main content

slopguard_core/source/
mod.rs

1//! External rule sources: git repositories cloned into a local cache, plus
2//! extra local directories. Each configured `[[rules.sources]]` is resolved to
3//! a directory of rule YAML and the provenance stamped on the rules loaded
4//! from it.
5//!
6//! External sources are untrusted input, so every git call is hardened:
7//!
8//! - A URL or ref starting with `-` is rejected before git runs (git would
9//!   parse it as an option such as `--upload-pack=...`), and the fetch passes
10//!   `--` before its positional arguments.
11//! - `GIT_ALLOW_PROTOCOL` restricts git to `https` and `ssh` (no `file://`,
12//!   `ext::` or other transports). `SLOPGUARD_GIT_ALLOW_PROTOCOL` overrides the
13//!   list; it exists for tests and CI that serve rules from `file://` remotes.
14//! - A ref that is not a full 40-character commit sha is floating: the rules
15//!   it loads can change under the user, so the CLI warns about it
16//!   ([`unpinned_source_warnings`]). A pinned sha already checked out in the
17//!   cache is reused without touching the network.
18//! - Refreshing an existing cache when online must succeed: a failed fetch
19//!   fails the resolution instead of silently scanning with stale rules.
20//!
21//! Git auth is delegated to the machine's own git (ssh-agent, credential
22//! helper, `~/.gitconfig`). `SLOPGUARD_GIT_TOKEN` overrides that for https URLs
23//! of a single trusted host (`SLOPGUARD_GIT_TOKEN_HOST`, else `[git]
24//! token_host` in the user config): the token is injected into the transient
25//! `git fetch <url>` argument only, never into the persisted remote, so no
26//! token is written to disk. Without a configured host, no token is injected.
27
28use std::env;
29use std::fmt;
30use std::fs::create_dir_all;
31use std::io::Error as IoError;
32use std::path::{Path, PathBuf};
33use std::process::{Command, Output};
34
35use sha2::{Digest, Sha256};
36use thiserror::Error;
37
38use crate::config::RuleSource;
39use crate::sanitize::sanitize_control;
40
41/// Environment variable holding an https token that overrides git's own
42/// credential resolution. Empty means "unset".
43const GIT_TOKEN_ENV: &str = "SLOPGUARD_GIT_TOKEN";
44
45/// Environment variable naming the only host the token may be sent to. Takes
46/// precedence over `[git] token_host`. Empty means "unset".
47const GIT_TOKEN_HOST_ENV: &str = "SLOPGUARD_GIT_TOKEN_HOST";
48
49/// Environment override of the protocols git may use for sources, in
50/// `GIT_ALLOW_PROTOCOL` syntax (colon-separated). Meant for tests and CI that
51/// serve rules from `file://` remotes. Empty means "unset".
52const ALLOW_PROTOCOL_ENV: &str = "SLOPGUARD_GIT_ALLOW_PROTOCOL";
53
54/// Protocols git may use to fetch a source unless overridden.
55pub const DEFAULT_ALLOWED_PROTOCOLS: &str = "https:ssh";
56
57const HTTPS_PREFIX: &str = "https://";
58
59/// Replacement for secrets in error text.
60const REDACTED: &str = "<redacted>";
61
62/// An https token together with the single host it may be sent to.
63#[derive(Clone, PartialEq, Eq)]
64pub struct GitToken {
65    /// The secret, injected as `oauth2:<token>@` basic auth.
66    pub token: String,
67    /// The only host (port excluded, compared case-insensitively) that may
68    /// receive the token.
69    pub host: String,
70}
71
72impl fmt::Debug for GitToken {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        // The secret never appears in debug output.
75        f.debug_struct("GitToken")
76            .field("token", &REDACTED)
77            .field("host", &self.host)
78            .finish()
79    }
80}
81
82/// Read the git token from `SLOPGUARD_GIT_TOKEN`, bound to the host named by
83/// `SLOPGUARD_GIT_TOKEN_HOST`, else `config_host` (`[git] token_host`).
84///
85/// Returns `None` when the token or the host is missing: a token is never sent
86/// to a host the user did not name.
87pub fn git_token_from_env(config_host: Option<&str>) -> Option<GitToken> {
88    let token = non_empty_env(GIT_TOKEN_ENV)?;
89    let config_host = config_host.filter(|h| !h.is_empty()).map(str::to_string);
90    let host = non_empty_env(GIT_TOKEN_HOST_ENV).or(config_host)?;
91    Some(GitToken { token, host })
92}
93
94/// The protocols git may use for sources: `$SLOPGUARD_GIT_ALLOW_PROTOCOL` when
95/// set, otherwise [`DEFAULT_ALLOWED_PROTOCOLS`].
96pub fn default_allowed_protocols() -> String {
97    non_empty_env(ALLOW_PROTOCOL_ENV).unwrap_or_else(|| DEFAULT_ALLOWED_PROTOCOLS.to_string())
98}
99
100fn non_empty_env(name: &str) -> Option<String> {
101    env::var(name).ok().filter(|value| !value.is_empty())
102}
103
104/// Where a rule came from, tracked so `slopguard list` can show provenance.
105#[derive(Debug, Clone, PartialEq, Eq, Default)]
106pub enum RuleOrigin {
107    /// Embedded in the binary.
108    #[default]
109    Builtin,
110    /// A git source, identified by its clone URL.
111    Git { url: String },
112    /// A local directory (`rules.custom_dirs` or a local `[[rules.sources]]`).
113    Local { path: PathBuf },
114}
115
116impl RuleOrigin {
117    /// The `source` string shown by `slopguard list`: `"builtin"`, the git URL,
118    /// or the local path.
119    pub fn label(&self) -> String {
120        match self {
121            RuleOrigin::Builtin => "builtin".to_string(),
122            RuleOrigin::Git { url } => sanitize_control(url).into_owned(),
123            RuleOrigin::Local { path } => {
124                sanitize_control(&path.display().to_string()).into_owned()
125            }
126        }
127    }
128}
129
130/// A source resolved to a directory on disk plus the provenance of its rules.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ResolvedSource {
133    /// Directory to load rule YAML from.
134    pub dir: PathBuf,
135    /// Provenance to stamp on every rule loaded from `dir`.
136    pub origin: RuleOrigin,
137}
138
139/// How git sources are cached, fetched and authenticated.
140#[derive(Debug, Clone)]
141pub struct SourceOptions {
142    /// Base directory holding one sub-directory per cached git source.
143    pub cache_root: PathBuf,
144    /// Never touch the network: reuse the cache, and fail if it is missing.
145    pub offline: bool,
146    /// Token injected into https fetches of its host only.
147    pub git_token: Option<GitToken>,
148    /// `GIT_ALLOW_PROTOCOL` value set on every git call (colon-separated).
149    pub allowed_protocols: String,
150}
151
152impl Default for SourceOptions {
153    fn default() -> Self {
154        Self {
155            cache_root: default_cache_root(),
156            offline: false,
157            git_token: git_token_from_env(None),
158            allowed_protocols: default_allowed_protocols(),
159        }
160    }
161}
162
163/// Environment override for the git source cache root, taking precedence over
164/// the default user cache location. Useful in CI and tests.
165const CACHE_ENV: &str = "SLOPGUARD_SOURCES_CACHE";
166
167/// Default cache location for git sources: `$SLOPGUARD_SOURCES_CACHE` when set,
168/// otherwise `<user cache>/slopguard/sources`.
169pub fn default_cache_root() -> PathBuf {
170    if let Ok(dir) = env::var(CACHE_ENV) {
171        if !dir.is_empty() {
172            return PathBuf::from(dir);
173        }
174    }
175    dirs::cache_dir()
176        .unwrap_or_else(|| PathBuf::from(".cache"))
177        .join("slopguard")
178        .join("sources")
179}
180
181#[derive(Debug, Error)]
182pub enum SourceError {
183    #[error("failed to run git (is it installed and on PATH?): {source}")]
184    Spawn {
185        #[source]
186        source: IoError,
187    },
188
189    #[error("git {args} failed for source '{url}': {stderr}")]
190    Git {
191        url: String,
192        args: String,
193        stderr: String,
194    },
195
196    #[error("offline: no cached clone for git source '{url}' (expected at '{path}')")]
197    OfflineNoCache { url: String, path: String },
198
199    #[error("failed to prepare cache directory '{path}': {source}")]
200    Cache {
201        path: String,
202        #[source]
203        source: IoError,
204    },
205
206    #[error("local rule source '{path}' does not exist or is not a directory")]
207    LocalMissing { path: String },
208
209    #[error("git source {what} '{value}' must not start with '-'")]
210    InvalidArgument { what: &'static str, value: String },
211}
212
213/// Whether `git_ref` is a full 40-character hex commit sha, i.e. a pinned ref
214/// whose content cannot change.
215pub fn is_pinned_sha(git_ref: &str) -> bool {
216    git_ref.len() == 40 && git_ref.bytes().all(|b| b.is_ascii_hexdigit())
217}
218
219/// One warning per git source whose ref is missing or is not a pinned commit
220/// sha. The URL is printed without its userinfo, so no credential leaks.
221pub fn unpinned_source_warnings(sources: &[RuleSource]) -> Vec<String> {
222    sources
223        .iter()
224        .filter_map(|source| {
225            let url = source.git.as_deref()?;
226            let reason = match source.git_ref.as_deref() {
227                Some(git_ref) if is_pinned_sha(git_ref) => return None,
228                Some(git_ref) => format!(
229                    "ref '{}' is not a 40-character commit sha",
230                    sanitize_control(git_ref)
231                ),
232                None => "no ref set, the default branch is used".to_string(),
233            };
234            Some(format!(
235                "unpinned rule source '{}': {reason}; pin it to a commit sha so its rules cannot change unnoticed",
236                sanitize_control(&without_userinfo(url))
237            ))
238        })
239        .collect()
240}
241
242/// The authority of a `scheme://authority/path` URL, or `None` without `://`.
243fn url_authority(url: &str) -> Option<&str> {
244    let (_, rest) = url.split_once("://")?;
245    Some(rest.split('/').next().unwrap_or(rest))
246}
247
248/// The host of a URL: the authority after its last `@` (userinfo is never the
249/// host), without `:port`. `https://trusted.com@evil.com/x` yields `evil.com`.
250fn url_host(url: &str) -> Option<&str> {
251    let authority = url_authority(url)?;
252    let host_port = authority.rsplit('@').next().unwrap_or(authority);
253    Some(host_port.split(':').next().unwrap_or(host_port))
254}
255
256/// `url` with any userinfo replaced by a placeholder, for messages.
257fn without_userinfo(url: &str) -> String {
258    let Some((scheme, rest)) = url.split_once("://") else {
259        return url.to_string();
260    };
261    let authority = rest.split('/').next().unwrap_or(rest);
262    match authority.rsplit_once('@') {
263        Some((_, host)) => {
264            let path = &rest[authority.len()..];
265            format!("{scheme}://{REDACTED}@{host}{path}")
266        }
267        None => url.to_string(),
268    }
269}
270
271/// The cache sub-directory for a git source, keyed by a hash of its URL and
272/// ref so two refs of the same repository never share a checkout.
273fn cache_dir_for(root: &Path, url: &str, git_ref: Option<&str>) -> PathBuf {
274    let mut hasher = Sha256::new();
275    hasher.update(url.as_bytes());
276    hasher.update([0u8]);
277    if let Some(git_ref) = git_ref {
278        hasher.update(git_ref.as_bytes());
279    }
280    let digest = hasher.finalize();
281    let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
282    root.join(hex)
283}
284
285/// The URL passed to a transient `git fetch`. The token is injected as basic
286/// auth only for an https URL without userinfo whose host is the token's host;
287/// otherwise the URL is returned unchanged and git resolves credentials itself.
288fn fetch_url(url: &str, token: Option<&GitToken>) -> String {
289    let (Some(token), Some(rest)) = (token, url.strip_prefix(HTTPS_PREFIX)) else {
290        return url.to_string();
291    };
292    let has_userinfo = url_authority(url).is_some_and(|authority| authority.contains('@'));
293    let trusted = url_host(url).is_some_and(|host| host.eq_ignore_ascii_case(&token.host));
294    if has_userinfo || !trusted {
295        return url.to_string();
296    }
297    format!("{HTTPS_PREFIX}oauth2:{}@{rest}", token.token)
298}
299
300/// Reject a URL or ref that git would parse as an option (`--upload-pack=...`).
301fn validate_git_arg(what: &'static str, value: &str) -> Result<(), SourceError> {
302    if value.starts_with('-') {
303        return Err(SourceError::InvalidArgument {
304            what,
305            value: sanitize_control(value).into_owned(),
306        });
307    }
308    Ok(())
309}
310
311/// Run git in `dir`, restricted to the `allowed_protocols` transports.
312fn run_git(dir: &Path, args: &[&str], allowed_protocols: &str) -> Result<Output, SourceError> {
313    Command::new("git")
314        .current_dir(dir)
315        .args(args)
316        .env("GIT_ALLOW_PROTOCOL", allowed_protocols)
317        .output()
318        .map_err(|source| SourceError::Spawn { source })
319}
320
321fn raw_stderr_of(output: &Output) -> String {
322    String::from_utf8_lossy(&output.stderr).trim().to_string()
323}
324
325/// The trimmed stderr of a git call with control characters neutralized.
326fn stderr_of(output: &Output) -> String {
327    sanitize_control(&raw_stderr_of(output)).into_owned()
328}
329
330/// Shallow-fetch `git_ref` from `url` into an existing repo at `dir` and check
331/// it out detached. The token (if any) lives only in this fetch argument, never
332/// in the repo's persisted remote, and is redacted from the error text.
333fn fetch_and_checkout(
334    dir: &Path,
335    url: &str,
336    git_ref: Option<&str>,
337    opts: &SourceOptions,
338) -> Result<(), SourceError> {
339    let refspec = git_ref.unwrap_or("HEAD");
340    validate_git_arg("url", url)?;
341    validate_git_arg("ref", refspec)?;
342    let token = opts.git_token.as_ref();
343    let tokened = fetch_url(url, token);
344    let fetch = run_git(
345        dir,
346        &["fetch", "--depth", "1", "--", &tokened, refspec],
347        &opts.allowed_protocols,
348    )?;
349    if !fetch.status.success() {
350        let mut stderr = raw_stderr_of(&fetch);
351        if let Some(token) = token {
352            stderr = stderr.replace(&token.token, REDACTED);
353        }
354        return Err(SourceError::Git {
355            url: sanitize_control(url).into_owned(),
356            args: format!("fetch {}", sanitize_control(refspec)),
357            stderr: sanitize_control(&stderr).into_owned(),
358        });
359    }
360    let checkout = run_git(
361        dir,
362        &[
363            "-c",
364            "advice.detachedHead=false",
365            "checkout",
366            "--force",
367            "FETCH_HEAD",
368        ],
369        &opts.allowed_protocols,
370    )?;
371    if !checkout.status.success() {
372        return Err(SourceError::Git {
373            url: sanitize_control(url).into_owned(),
374            args: "checkout FETCH_HEAD".to_string(),
375            stderr: stderr_of(&checkout),
376        });
377    }
378    Ok(())
379}
380
381/// Whether the cached repo at `dir` already has the pinned `sha` checked out.
382fn is_checked_out(dir: &Path, sha: &str, allowed_protocols: &str) -> bool {
383    let Ok(output) = run_git(dir, &["rev-parse", "HEAD"], allowed_protocols) else {
384        return false;
385    };
386    let head = String::from_utf8_lossy(&output.stdout);
387    output.status.success() && head.trim().eq_ignore_ascii_case(sha)
388}
389
390/// Ensure a git source is present in the cache and checked out at `git_ref`,
391/// returning the repository directory.
392///
393/// The URL and ref are validated before any git process runs. A missing cache
394/// is cloned (unless offline). An existing cache is refreshed when online, and
395/// a failed refresh is an error: scanning with stale rules would hide that the
396/// source can no longer be verified. A pinned sha already checked out is
397/// reused as is, without touching the network.
398fn ensure_git_source(
399    url: &str,
400    git_ref: Option<&str>,
401    opts: &SourceOptions,
402) -> Result<PathBuf, SourceError> {
403    validate_git_arg("url", url)?;
404    if let Some(git_ref) = git_ref {
405        validate_git_arg("ref", git_ref)?;
406    }
407    let dir = cache_dir_for(&opts.cache_root, url, git_ref);
408    let is_repo = dir.join(".git").is_dir();
409
410    if !is_repo {
411        if opts.offline {
412            return Err(SourceError::OfflineNoCache {
413                url: sanitize_control(url).into_owned(),
414                path: sanitize_control(&dir.display().to_string()).into_owned(),
415            });
416        }
417        create_dir_all(&dir).map_err(|source| SourceError::Cache {
418            path: dir.display().to_string(),
419            source,
420        })?;
421        let init = run_git(&dir, &["init", "--quiet"], &opts.allowed_protocols)?;
422        if !init.status.success() {
423            return Err(SourceError::Git {
424                url: sanitize_control(url).into_owned(),
425                args: "init".to_string(),
426                stderr: stderr_of(&init),
427            });
428        }
429        fetch_and_checkout(&dir, url, git_ref, opts)?;
430    } else if !opts.offline {
431        let pinned_present = git_ref.is_some_and(|sha| {
432            is_pinned_sha(sha) && is_checked_out(&dir, sha, &opts.allowed_protocols)
433        });
434        if !pinned_present {
435            fetch_and_checkout(&dir, url, git_ref, opts)?;
436        }
437    }
438
439    Ok(dir)
440}
441
442/// Resolve every configured source to a directory and its provenance.
443///
444/// Sources are validated at config load, so each entry has a `git` URL or a
445/// local `path`. Git sources are cloned/refreshed under `opts.cache_root`;
446/// local sources must already exist.
447pub fn resolve_sources(
448    sources: &[RuleSource],
449    opts: &SourceOptions,
450) -> Result<Vec<ResolvedSource>, SourceError> {
451    let mut resolved = Vec::with_capacity(sources.len());
452    for source in sources {
453        if let Some(url) = &source.git {
454            let repo = ensure_git_source(url, source.git_ref.as_deref(), opts)?;
455            let dir = match &source.path {
456                Some(sub) => repo.join(sub),
457                None => repo,
458            };
459            resolved.push(ResolvedSource {
460                dir,
461                origin: RuleOrigin::Git { url: url.clone() },
462            });
463        } else if let Some(path) = &source.path {
464            if !path.is_dir() {
465                return Err(SourceError::LocalMissing {
466                    path: sanitize_control(&path.display().to_string()).into_owned(),
467                });
468            }
469            resolved.push(ResolvedSource {
470                dir: path.clone(),
471                origin: RuleOrigin::Local { path: path.clone() },
472            });
473        }
474    }
475    Ok(resolved)
476}
477
478#[cfg(test)]
479mod tests;