Compare commits

...
5 changed files with 59 additions and 42 deletions

No files matched your search

+12 -10
View File
@@ -1,11 +1,11 @@
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
const TOKEN_ENV: &str = "FIREWALL_GITHUB_TOKEN";
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
@@ -16,30 +16,32 @@ pub struct FirewallConfig {
/// 0 turns the age check off.
pub min_age_hours: u64,
pub denylist: DenylistSource,
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool,
/// Not describable in the file yet, so this is always the built-in table.
#[serde(skip)]
pub registries: Vec<&'static Registry>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowedPackage {
pub name: String,
/// Every version when absent.
pub version: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DenylistSource {
/// One file per ecosystem lives under here, named the way OSV names it, in lowercase.
pub base: String,
/// Falls back to `FIREWALL_GITHUB_TOKEN`, so a token need not be written to disk.
pub token: Option<String>,
/// Polled for a new commit, which re-fetches every list in use.
pub revision: String,
pub poll_secs: u64,
}
impl DenylistSource {
pub fn token(&self) -> Option<String> {
self.token.clone().or_else(|| std::env::var(TOKEN_ENV).ok())
}
}
impl Default for FirewallConfig {
fn default() -> Self {
FirewallConfig {
@@ -47,6 +49,7 @@ impl Default for FirewallConfig {
fail_open: true,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
registries: BUILT_IN.to_vec(),
}
@@ -57,7 +60,6 @@ impl Default for DenylistSource {
fn default() -> Self {
DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
token: None,
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
.into(),
poll_secs: 10,
+1 -1
View File
@@ -27,7 +27,7 @@ impl Firewall {
patterns.insert(registry.prefix, compiled.collect());
}
let ecosystems = config.registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), ecosystems);
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours)));
+14 -29
View File
@@ -3,7 +3,7 @@ use crate::config::DenylistSource;
use crate::policies::registries::Registry;
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::{AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::header::{ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use semver::Version;
@@ -37,20 +37,15 @@ type Lists = HashMap<&'static str, OnceCell<RwLock<Arc<FetchedDenylist>>>>;
pub struct Denylist {
source: DenylistSource,
token: Option<String>,
lists: Arc<Lists>,
watch: JoinHandle<()>,
}
impl Denylist {
pub fn new(
source: DenylistSource,
token: Option<String>,
ecosystems: impl Iterator<Item = &'static str>,
) -> Self {
pub fn new(source: DenylistSource, ecosystems: impl Iterator<Item = &'static str>) -> Self {
let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect());
let watch = tokio::spawn(watch(source.clone(), token.clone(), Arc::clone(&lists)));
Denylist { source, token, lists, watch }
let watch = tokio::spawn(watch(source.clone(), Arc::clone(&lists)));
Denylist { source, lists, watch }
}
}
@@ -69,7 +64,7 @@ impl Filter for Denylist {
Box::pin(async move {
let eco = registry.osv_name;
let load = || async {
RwLock::new(Arc::new(load(&self.source, self.token.as_deref(), eco).await))
RwLock::new(Arc::new(load(&self.source, eco).await))
};
let list = self.lists[eco].get_or_init(load).await;
let list = Arc::clone(&list.read().expect("denylist lock"));
@@ -147,12 +142,12 @@ pub fn describe(advisories: &[&Advisory]) -> String {
}
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>) {
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs);
let mut seen = revision(&source, token.as_deref(), None).await;
let mut seen = revision(&source, None).await;
loop {
tokio::time::sleep(period).await;
let changed = revision(&source, token.as_deref(), seen.as_deref()).await;
let changed = revision(&source, seen.as_deref()).await;
if let Some(current) = &changed {
info!(revision = %current, "denylist source changed");
seen = changed.clone();
@@ -163,7 +158,7 @@ async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>)
if changed.is_none() && !failed {
continue;
}
if let Ok(packages) = load(&source, token.as_deref(), eco).await {
if let Ok(packages) = load(&source, eco).await {
*list.write().expect("denylist lock") = Arc::new(Ok(packages));
}
}
@@ -171,16 +166,9 @@ async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>)
}
/// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit.
async fn revision(
source: &DenylistSource,
token: Option<&str>,
seen: Option<&str>,
) -> Option<String> {
async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String> {
let client = EasyHttpWebClient::default();
let mut request = client.get(&source.revision).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
if let Some(seen) = seen {
request = request.header(IF_NONE_MATCH, seen);
}
@@ -197,8 +185,8 @@ async fn revision(
response.headers().get(ETAG)?.to_str().ok().map(str::to_owned)
}
async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, token, &eco.to_lowercase()).await;
async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, &eco.to_lowercase()).await;
match &list {
Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"),
Err(error) => warn!(%eco, %error, "denylist unavailable"),
@@ -206,13 +194,10 @@ async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -
list
}
async fn fetch(source: &DenylistSource, token: Option<&str>, eco: &str) -> FetchedDenylist {
async fn fetch(source: &DenylistSource, eco: &str) -> FetchedDenylist {
let url = format!("{}/{eco}", source.base);
let client = EasyHttpWebClient::default();
let mut request = client.get(&url).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
let request = client.get(&url).header(USER_AGENT, AGENT);
let response = request.send().await.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
+18
View File
@@ -30,6 +30,9 @@ pub async fn consult(
registry: &'static Registry,
pkg: &Package,
) -> Option<(&'static str, Verdict)> {
if allowed(firewall, registry, pkg) {
return None;
}
let mut unsure = None;
for filter in firewall.filters() {
match filter.check(registry, pkg).await {
@@ -41,3 +44,18 @@ pub async fn consult(
}
unsure
}
fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = firewall.config.allow.get(registry.eco) else {
return false;
};
entries.iter().any(|entry| {
let name_matches = match registry.normalize {
Some(normalize) => normalize(&entry.name) == pkg.name,
None => entry.name == pkg.name,
};
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
name_matches && (version.is_none() || version == pkg.version.as_deref())
})
}
+14 -2
View File
@@ -1,12 +1,13 @@
use firewall::config::path;
use firewall::FirewallConfig;
use firewall::policies::filters::consult;
use firewall::policies::Package;
use firewall::{Firewall, FirewallConfig};
#[test]
fn a_file_only_has_to_set_what_it_changes() {
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
assert!(!config.fail_open);
assert_eq!(config.port, firewall::DEFAULT_PORT);
assert_eq!(config.denylist.token, FirewallConfig::default().denylist.token);
}
#[test]
@@ -19,3 +20,14 @@ fn the_config_lives_under_the_config_directory() {
let path = path().expect("a home directory");
assert!(path.ends_with("config.json"), "{}", path.display());
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
let config = r#"{"allow": {"go": [{"name": "golang.org/x/net", "version": "v0.20.0"}]}, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let (registry, _) = firewall.match_on("/go/golang.org/x/net/@v/list", |r| r.prefix).expect("go");
let pkg = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) };
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
}