Compare commits

..
Author SHA1 Message Date
Ian 9dc9c84b29 Screen against the packages Strix admins block 2026-10-22 10:31:05 -04:00
4 changed files with 112 additions and 6 deletions

No files matched your search

+5 -2
View File
@@ -18,7 +18,7 @@ pub struct FirewallConfig {
pub min_age_hours: u64,
pub denylist: DenylistSource,
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub allow: PackageList,
pub telemetry: bool,
/// Reports installs to, and takes blocks from, a Strix workspace.
pub platform: Option<PlatformConfig>,
@@ -27,9 +27,12 @@ pub struct FirewallConfig {
pub registries: Vec<&'static Registry>,
}
/// Packages by ecosystem, as the allow list and organization blocks name them.
pub type PackageList = HashMap<String, Vec<ListedPackage>>;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowedPackage {
pub struct ListedPackage {
pub name: String,
/// Every version when absent.
pub version: Option<String>,
+7 -1
View File
@@ -1,6 +1,7 @@
use crate::config::FirewallConfig;
use crate::policies::filters::age::Age;
use crate::policies::filters::denylist::Denylist;
use crate::policies::filters::organization::OrganizationBlocks;
use crate::policies::filters::osv::Osv;
use crate::policies::filters::Filter;
use crate::policies::registries::*;
@@ -28,7 +29,12 @@ impl Firewall {
}
let ecosystems = config.registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
let mut filters: Vec<Box<dyn Filter>> = Vec::new();
if let Some(platform) = &config.platform {
filters.push(Box::new(OrganizationBlocks::new(platform.clone())));
}
filters.push(Box::new(denylist));
filters.push(Box::new(Osv));
if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours)));
}
+6 -3
View File
@@ -1,8 +1,10 @@
pub mod age;
pub mod denylist;
pub mod organization;
pub mod osv;
use super::registries::{Package, Registry};
use crate::config::PackageList;
use crate::Firewall;
use std::future::Future;
use std::pin::Pin;
@@ -30,7 +32,7 @@ pub async fn consult(
registry: &'static Registry,
pkg: &Package,
) -> Option<(&'static str, Verdict)> {
if allowed(firewall, registry, pkg) {
if listed(&firewall.config.allow, registry, pkg) {
return None;
}
let mut unsure = None;
@@ -45,8 +47,9 @@ pub async fn consult(
unsure
}
fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = firewall.config.allow.get(registry.eco) else {
/// Whether `list` names `pkg`.
fn listed(list: &PackageList, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = list.get(registry.eco) else {
return false;
};
entries.iter().any(|entry| {
+94
View File
@@ -0,0 +1,94 @@
use super::{listed, Filter, Verdict, VerdictFuture, AGENT};
use crate::config::{PackageList, PlatformConfig};
use crate::policies::registries::Registry;
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::{AUTHORIZATION, USER_AGENT};
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::sync::OnceCell;
use tokio::task::JoinHandle;
use tracing::{info, warn};
const POLL: Duration = Duration::from_secs(60);
type Blocks = Arc<OnceCell<RwLock<Arc<Result<PackageList, String>>>>>;
/// Packages a Strix admin blocked, downloaded when the first package is screened.
pub struct OrganizationBlocks {
platform: PlatformConfig,
blocks: Blocks,
watch: JoinHandle<()>,
}
impl OrganizationBlocks {
pub fn new(platform: PlatformConfig) -> Self {
let blocks = Arc::new(OnceCell::new());
let watch = tokio::spawn(watch(platform.clone(), Arc::clone(&blocks)));
OrganizationBlocks { platform, blocks, watch }
}
}
impl Drop for OrganizationBlocks {
fn drop(&mut self) {
self.watch.abort();
}
}
impl Filter for OrganizationBlocks {
fn name(&self) -> &'static str {
"organization"
}
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
Box::pin(async move {
let load = || async { RwLock::new(Arc::new(load(&self.platform).await)) };
let blocks = self.blocks.get_or_init(load).await;
let blocks = Arc::clone(&blocks.read().expect("blocks lock"));
match &*blocks {
Err(e) => Verdict::Unknown(e.clone()),
Ok(blocks) if listed(blocks, registry, pkg) => {
Verdict::Deny("is blocked by your organization".into())
}
Ok(_) => Verdict::Allow,
}
})
}
}
/// A failed fetch leaves the last good list in place.
async fn watch(platform: PlatformConfig, blocks: Blocks) {
loop {
tokio::time::sleep(POLL).await;
let Some(current) = blocks.get() else { continue };
if let Ok(fresh) = load(&platform).await {
*current.write().expect("blocks lock") = Arc::new(Ok(fresh));
}
}
}
async fn load(platform: &PlatformConfig) -> Result<PackageList, String> {
let blocks = fetch(platform).await;
match &blocks {
Ok(_) => info!("organization blocks loaded"),
Err(error) => warn!(%error, "organization blocks unavailable"),
}
blocks
}
async fn fetch(platform: &PlatformConfig) -> Result<PackageList, String> {
let url = format!("{}/api/v1/firewall/blocklist", platform.url);
let client = EasyHttpWebClient::default();
let request = client
.get(&url)
.header(USER_AGENT, AGENT)
.header(AUTHORIZATION, format!("Bearer {}", platform.api_key));
let response = request.send().await.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())?;
serde_json::from_str(&text).map_err(|e| e.to_string())
}