Compare commits

..
Author SHA1 Message Date
Ian Graham Martinez 20c781404f Sign an empty CRL and point leaves at it
schannel's revocation check, which cargo enables, fails closed on a leaf
without a reachable CRL. The CA gains cRLSign, which rcgen needs to sign
one, and leaves carry a distribution point when the platform serves it.
2026-10-13 10:51:44 -04:00
Ian Graham Martinez 0ba2e23032 List the screened hosts from the config
Building a Firewall needs a runtime, which an installer has no use for.
2026-10-13 10:51:44 -04:00
15 changed files with 12 additions and 226 deletions

No files matched your search

+1 -5
View File
@@ -44,10 +44,6 @@ path = "tests/denylist.rs"
name = "osv"
path = "tests/osv.rs"
[[test]]
name = "age"
path = "tests/age.rs"
[[test]]
name = "mitm"
path = "tests/mitm.rs"
@@ -67,7 +63,7 @@ regex = "1.13.1"
semver = "1.0.28"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
time = { version = "0.3.47", features = ["formatting", "parsing"] }
time = { version = "0.3.47", features = ["formatting"] }
tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
-15
View File
@@ -1,7 +1,6 @@
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;
@@ -14,25 +13,13 @@ pub struct FirewallConfig {
pub port: u16,
/// Allow a package when a filter could not check it.
pub fail_open: bool,
/// 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 {
@@ -56,9 +43,7 @@ impl Default for FirewallConfig {
FirewallConfig {
port: DEFAULT_PORT,
fail_open: true,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
registries: BUILT_IN.to_vec(),
}
+1 -5
View File
@@ -1,5 +1,4 @@
use crate::config::FirewallConfig;
use crate::policies::filters::age::Age;
use crate::policies::filters::denylist::Denylist;
use crate::policies::filters::osv::Osv;
use crate::policies::filters::Filter;
@@ -28,10 +27,7 @@ impl Firewall {
}
let ecosystems = config.registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), 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)));
}
let filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
Firewall { config, patterns, filters }
}
-72
View File
@@ -1,72 +0,0 @@
use super::{Filter, Verdict, VerdictFuture, AGENT};
use crate::policies::registries::{Published, Registry};
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::USER_AGENT;
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
pub struct Age {
min: Duration,
}
impl Age {
pub fn new(hours: u64) -> Self {
Age { min: Duration::hours(hours as i64) }
}
}
impl Filter for Age {
fn name(&self) -> &'static str {
"age"
}
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
Box::pin(async move {
let Some(version) = pkg.version.as_deref() else { return Verdict::Allow };
let Some(published) = &registry.published else { return Verdict::Allow };
let published_at = match published_at(published, &pkg.name, version).await {
Ok(at) => at,
Err(e) => return Verdict::Unknown(e),
};
let age = OffsetDateTime::now_utc() - published_at;
if age >= self.min {
return Verdict::Allow;
}
let (hours, min) = (age.whole_hours(), self.min.whole_hours());
Verdict::Deny(format!("was published {hours} hours ago, under the {min}-hour minimum"))
})
}
}
async fn published_at(
published: &Published,
name: &str,
version: &str,
) -> Result<OffsetDateTime, String> {
let name = match published.escape {
Some(escape) => escape(name),
None => name.to_string(),
};
let url = published.url.replace("{name}", &name).replace("{version}", version);
let pointer = published.pointer.replace("{version}", version);
let client = EasyHttpWebClient::default();
let response = client.get(&url).header(USER_AGENT, AGENT).send().await;
let response = response.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
}
let text = response.try_into_string().await.map_err(|e| e.to_string())?;
let body: Value =
serde_json::from_str(&text).map_err(|e| format!("{url} sent unreadable JSON: {e}"))?;
let Some(stamp) = body.pointer(&pointer).and_then(Value::as_str) else {
return Err(format!("{url} gave no publish time"));
};
OffsetDateTime::parse(stamp, &Rfc3339).map_err(|e| format!("{url} sent a bad time: {e}"))
}
+4 -1
View File
@@ -1,4 +1,4 @@
use super::{Filter, Verdict, VerdictFuture, AGENT};
use super::{Filter, Verdict, VerdictFuture};
use crate::config::DenylistSource;
use crate::policies::registries::Registry;
use crate::policies::Package;
@@ -14,6 +14,9 @@ use tokio::sync::OnceCell;
use tokio::task::JoinHandle;
use tracing::{info, warn};
/// The GitHub API turns away requests without a user agent.
const AGENT: &str = "strix-firewall";
#[derive(Debug, PartialEq)]
pub enum AffectedVersions {
All,
-22
View File
@@ -1,4 +1,3 @@
pub mod age;
pub mod denylist;
pub mod osv;
@@ -7,9 +6,6 @@ use crate::Firewall;
use std::future::Future;
use std::pin::Pin;
/// Some registry APIs turn away requests without a user agent.
const AGENT: &str = "strix-firewall";
pub type VerdictFuture<'a> = Pin<Box<dyn Future<Output = Verdict> + Send + 'a>>;
/// `Unknown` means the filter could not check the package.
@@ -30,9 +26,6 @@ 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 {
@@ -44,18 +37,3 @@ 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())
})
}
-3
View File
@@ -72,9 +72,6 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
Some((_, Verdict::Deny(reason))) => {
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
Some(format!("{} could not be checked by {filter} ({e})", pkg.label()))
}
_ => None,
}
}
+1 -8
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static CARGO_INDEX: Registry = Registry {
prefix: "/cargo/index/",
@@ -16,7 +16,6 @@ pub static CARGO_INDEX: Registry = Registry {
// Sparse index paths shard by name: le/ft/left-pad, 3/s/syn, 2/cc.
Pattern { re: r"(?<name>[^/]+)$", rewrite: None },
],
published: None,
};
pub static CARGO_DL: Registry = Registry {
@@ -27,11 +26,6 @@ pub static CARGO_DL: Registry = Registry {
index: false,
normalize: None,
patterns: &[Pattern { re: r"^(?<name>[^/]+)/(?<version>[^/]+)", rewrite: None }],
published: Some(Published {
url: "https://crates.io/api/v1/crates/{name}/{version}",
pointer: "/version/created_at",
escape: None,
}),
};
/// `cargo add` resolves versions here, named by config.json's "api" field.
@@ -43,5 +37,4 @@ pub static CRATES_API: Registry = Registry {
index: false,
normalize: None,
patterns: &[],
published: None,
};
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static GEM: Registry = Registry {
prefix: "/gem/",
@@ -16,9 +16,4 @@ pub static GEM: Registry = Registry {
// The compact index, which names no version.
Pattern { re: r"^info/(?<name>[^/]+)$", rewrite: None },
],
published: Some(Published {
url: "https://rubygems.org/api/v2/rubygems/{name}/versions/{version}.json",
pointer: "/created_at",
escape: None,
}),
};
+1 -17
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static GO: Registry = Registry {
prefix: "/go/",
@@ -13,11 +13,6 @@ pub static GO: Registry = Registry {
// The version list and the latest lookup, which name no version.
Pattern { re: r"^(?<name>.+)/@(?:v/list|latest)$", rewrite: None },
],
published: Some(Published {
url: "https://proxy.golang.org/{name}/@v/v{version}.info",
pointer: "/Time",
escape: Some(escape),
}),
};
/// The proxy protocol writes every capital as !<lowercase>; advisories do not.
@@ -32,14 +27,3 @@ fn unescape(path: &str) -> String {
}
out
}
fn escape(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for c in path.chars() {
if c.is_ascii_uppercase() {
out.push('!');
}
out.push(c.to_ascii_lowercase());
}
out
}
-9
View File
@@ -48,15 +48,6 @@ pub struct Registry {
/// PyPI escapes a name into a filename and Go escapes its capitals; advisories do neither.
pub normalize: Option<fn(&str) -> String>,
pub patterns: &'static [Pattern],
pub published: Option<Published>,
}
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
#[derive(Debug)]
pub struct Published {
pub url: &'static str,
pub pointer: &'static str,
pub escape: Option<fn(&str) -> String>,
}
impl Registry {
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static NPM: Registry = Registry {
prefix: "/npm/",
@@ -18,9 +18,4 @@ pub static NPM: Registry = Registry {
rewrite: Some(("https://registry.npmjs.org/", "/npm/")),
},
],
published: Some(Published {
url: "https://registry.npmjs.org/{name}",
pointer: "/time/{version}",
escape: None,
}),
};
+1 -7
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
const FILES: (&str, &str) = ("https://files.pythonhosted.org/", "/pypi-files/");
@@ -13,7 +13,6 @@ pub static PYPI: Registry = Registry {
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
Pattern { re: "", rewrite: Some(FILES) },
],
published: None,
};
/// The directories are hash shards, but the filename names the distribution.
@@ -36,11 +35,6 @@ pub static PYPI_FILES: Registry = Registry {
rewrite: None,
},
],
published: Some(Published {
url: "https://pypi.org/pypi/{name}/{version}/json",
pointer: "/urls/0/upload_time_iso_8601",
escape: None,
}),
};
/// PEP 503: runs of - _ and . collapse to one -, lowercased, which also un-escapes a wheel.
-27
View File
@@ -1,27 +0,0 @@
use firewall::policies::screen;
use firewall::{Firewall, FirewallConfig};
use rama::rt::blocking::Runtime;
const OLD_DOWNLOADS: &[&str] = &[
"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
"https://files.pythonhosted.org/packages/aa/bb/requests-2.31.0-py3-none-any.whl",
"https://static.crates.io/crates/serde/1.0.200",
"https://rubygems.org/gems/rake-13.0.6.gem",
"https://proxy.golang.org/github.com/!burnt!sushi/toml/@v/v1.3.2.zip",
];
#[test]
#[ignore = "queries every registry"]
fn every_registry_reports_when_a_version_was_published() {
let runtime = Runtime::try_new().expect("runtime");
runtime.block_on(async {
let firewall =
|hours| Firewall::new(FirewallConfig { min_age_hours: hours, ..Default::default() });
let (lenient, strict) = (firewall(24), firewall(24 * 365 * 100));
for url in OLD_DOWNLOADS {
assert_eq!(screen(url, &lenient).await, None, "{url}");
let blocked = screen(url, &strict).await.unwrap_or_default();
assert!(blocked.contains("hour minimum"), "{url}: {blocked:?}");
}
});
}
+1 -23
View File
@@ -1,7 +1,5 @@
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::{screen, Package};
use firewall::{Firewall, FirewallConfig};
use firewall::FirewallConfig;
#[test]
fn a_file_only_has_to_set_what_it_changes() {
@@ -21,23 +19,3 @@ 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());
}
#[tokio::test]
async fn failing_closed_blocks_what_could_not_be_checked() {
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
let blocked = screen(url, &firewall).await.unwrap_or_default();
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}