Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

**[Less disk use](https://aube.en.dev/package-manager/node-modules).** A global content-addressable store lets projects share package files instead of keeping a full copy of the same dependencies in every checkout.

**[Secure defaults](https://aube.en.dev/package-manager/jailed-builds).** aube defaults to safer installs: new releases wait out a minimum age, exotic transitive dependencies are blocked, and approved lifecycle scripts can run in a package-scoped jail.
**[Secure defaults](https://aube.en.dev/security).** Out of the box, exotic transitive deps are blocked, lifecycle scripts wait for approval, trust downgrades fail at resolve, and brand-new releases sit in a 24h cooling window. One `paranoid: true` line adds the build jail and turns the soft gates into hard fails.

## Install

Expand Down
33 changes: 33 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Security

## Reporting a vulnerability

Please report security issues privately via GitHub's security advisory form:
**[github.com/endevco/aube/security/advisories/new](https://github.com/endevco/aube/security/advisories/new)**

Do not file a public discussion for vulnerabilities. We will acknowledge
receipt within a few business days, work with you on a fix, and credit you in
the release notes unless you prefer otherwise.

## Security features at a glance

aube ships with several supply-chain protections enabled by default and
several more available as one-line opt-ins. Full reference:
**[aube.en.dev/security](https://aube.en.dev/security)**.

| Setting | Default | What it protects against |
| --- | --- | --- |
| `blockExoticSubdeps` | `true` | Transitive deps from `git+`, `file:`, or raw tarball URLs |
| `allowBuilds` (deny-by-default) | `true` | Lifecycle scripts running without explicit approval |
| `trustPolicy` | `"no-downgrade"` | Versions that lost provenance or trusted-publisher evidence |
| `minimumReleaseAge` | `1440` (24h) | Newly published versions before they have aged in the registry |
| `jailBuilds` | `false` (planned `true` in v2) | Approved scripts with full filesystem / network / env |
| `paranoid` | `false` | Master switch — forces `jailBuilds`, `trustPolicy=no-downgrade`, `minimumReleaseAgeStrict`, `strictStoreIntegrity`, `strictDepBuilds` |
| Tarball integrity (SHA-512) | always on | Tampered tarballs in the registry or proxy cache |
| Content-addressed store (BLAKE3) | always on | Drift between the store and the linked `node_modules` |
| `aube audit` | n/a | Known CVEs against the resolved dependency tree |

## Supported versions

Security fixes target the latest minor of the current major release. Older
majors do not receive backports.
6 changes: 6 additions & 0 deletions crates/aube-manifest/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ pub struct WorkspaceConfig {
#[serde(default, rename = "jailBuilds")]
pub jail_builds: Option<bool>,

/// Master switch that forces both `jailBuilds=true` and
/// `trustPolicy=no-downgrade`. Same typed/raw duality as
/// `child_concurrency`.
#[serde(default)]
pub paranoid: Option<bool>,

/// Dependency package patterns that should run outside the jail even
/// when `jailBuilds` is enabled. Same typed/raw duality as
/// `child_concurrency`.
Expand Down
99 changes: 99 additions & 0 deletions crates/aube-registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,14 @@ pub struct VersionMetadata {
/// Deprecation message from the registry, if this version is deprecated.
#[serde(default, deserialize_with = "deprecated_string")]
pub deprecated: Option<String>,
/// `_npmUser` block from the packument, when present. The
/// trust-policy check reads `_npmUser.trustedPublisher` as the
/// strongest trust-evidence signal (npm's "trusted publishers"
/// feature, OIDC-backed). Some old packuments emit `_npmUser` as
/// a `"name <email>"` string rather than an object — that shape
/// degrades to `None` instead of failing the whole packument.
#[serde(default, rename = "_npmUser", deserialize_with = "npm_user_tolerant")]
pub npm_user: Option<NpmUser>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
Expand All @@ -217,11 +225,33 @@ pub struct PeerDepMeta {
pub optional: bool,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct NpmUser {
/// Truthy when the publish came from an npm "trusted publisher"
/// (e.g. GitHub Actions OIDC). Pnpm's trust-policy check treats
/// presence of any non-null value here as the strongest evidence
/// (rank 2, above provenance attestations).
#[serde(default, rename = "trustedPublisher")]
pub trusted_publisher: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Dist {
pub tarball: String,
pub integrity: Option<String>,
pub shasum: Option<String>,
/// Sigstore attestations block. The trust-policy check reads
/// `dist.attestations.provenance` as a rank-1 trust-evidence
/// signal (`npm publish --provenance`). Existence is the only
/// signal we read — neither pnpm nor aube validates the bundle.
#[serde(default)]
pub attestations: Option<Attestations>,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Attestations {
#[serde(default)]
pub provenance: Option<serde_json::Value>,
}

fn deprecated_string<'de, D>(de: D) -> Result<Option<String>, D::Error>
Expand Down Expand Up @@ -286,6 +316,23 @@ where
})
}

/// Accept the packument's `_npmUser:` field in its documented shapes
/// and degrade to `None` for anything else. Modern packuments emit an
/// object (`{name, email, trustedPublisher?}`); pre-2010 publishes
/// emit `"name <email>"` strings. We only care about
/// `trustedPublisher`, so unparseable shapes don't fail the packument
/// — they just lose the trusted-publisher signal for that version.
fn npm_user_tolerant<'de, D>(de: D) -> Result<Option<NpmUser>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(de)?;
Ok(match value {
Some(v @ serde_json::Value::Object(_)) => serde_json::from_value(v).ok(),
_ => None,
})
}

/// Normalize `package.json` `bin` into a `name → path` map.
///
/// Two canonical shapes on the npm registry: a string
Expand Down Expand Up @@ -460,6 +507,7 @@ mod tests {
bin,
has_install_script: false,
deprecated: None,
npm_user: None,
};
let json = serde_json::to_string(&v).unwrap();
let back: VersionMetadata = serde_json::from_str(&json).unwrap();
Expand All @@ -478,6 +526,57 @@ mod tests {
assert!(v.libc.is_empty());
}

#[test]
fn attestations_provenance_is_extracted() {
let v = parse(
r#"{"name":"x","version":"1.0.0",
"dist":{"tarball":"t","attestations":{"provenance":{"predicateType":"slsa"}}}}"#,
);
let dist = v.dist.expect("dist present");
let att = dist.attestations.expect("attestations present");
assert!(att.provenance.is_some(), "provenance present");
}

#[test]
fn attestations_missing_is_none() {
let v = parse(r#"{"name":"x","version":"1.0.0","dist":{"tarball":"t"}}"#);
let dist = v.dist.expect("dist present");
assert!(dist.attestations.is_none());
}

#[test]
fn npm_user_object_with_trusted_publisher_is_parsed() {
let v = parse(
r#"{"name":"x","version":"1.0.0",
"_npmUser":{"name":"u","email":"u@x","trustedPublisher":{"id":"gh"}}}"#,
);
let user = v.npm_user.expect("_npmUser present");
assert!(user.trusted_publisher.is_some());
}

#[test]
fn npm_user_object_without_trusted_publisher_is_parsed() {
let v = parse(r#"{"name":"x","version":"1.0.0","_npmUser":{"name":"u","email":"u@x"}}"#);
let user = v.npm_user.expect("_npmUser present");
assert!(user.trusted_publisher.is_none());
}

/// Pre-2010 publishes serialize `_npmUser` as a `"name <email>"`
/// string. Degrade to `None` instead of failing the whole packument.
#[test]
fn npm_user_string_form_degrades_to_none() {
let v = parse(r#"{"name":"x","version":"1.0.0","_npmUser":"isaacs <i@npmjs.com>"}"#);
assert!(v.npm_user.is_none());
}

#[test]
fn npm_user_null_or_missing_is_none() {
let v_null = parse(r#"{"name":"x","version":"1.0.0","_npmUser":null}"#);
assert!(v_null.npm_user.is_none());
let v_missing = parse(r#"{"name":"x","version":"1.0.0"}"#);
assert!(v_missing.npm_user.is_none());
}

#[test]
fn deprecated_string_is_preserved_and_false_is_empty() {
let v = parse(r#"{"name":"x","version":"1.0.0","deprecated":"use y"}"#);
Expand Down
4 changes: 3 additions & 1 deletion crates/aube-resolver/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ impl Resolver {
/// two `resolved_times.insert` call sites on this predicate so
/// Highest-mode installs never populate the map.
pub(crate) fn should_record_times(&self) -> bool {
self.resolution_mode == ResolutionMode::TimeBased || self.minimum_release_age.is_some()
self.resolution_mode == ResolutionMode::TimeBased
|| self.minimum_release_age.is_some()
|| self.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade
}

/// Override the default `auto-install-peers=true` behavior. pnpm reads
Expand Down
35 changes: 35 additions & 0 deletions crates/aube-resolver/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::ResolveTask;
use crate::semver_util::highest_stable_version;
use crate::trust::{MissingTimeDetails, TrustDowngradeDetails};
use aube_registry::Packument;

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -28,6 +29,17 @@ pub enum Error {
.0.name, .0.spec, .0.parent
)]
BlockedExoticSubdep(Box<ExoticSubdepDetails>),
#[error(
"trust downgrade for {}@{} (trustPolicy=no-downgrade): earlier published version {} had {} but this version has {}",
.0.name, .0.picked_version, .0.prior_version, .0.prior_evidence.label(),
.0.current_evidence.map_or("no trust evidence", |e| e.label())
)]
TrustDowngrade(Box<TrustDowngradeDetails>),
#[error(
"trust check failed for {}@{} (trustPolicy=no-downgrade): registry packument has no `time` entry for the picked version",
.0.name, .0.version
)]
TrustCheckMissingTime(Box<MissingTimeDetails>),
}

/// Context attached to a `NoMatch` error so the miette `help()` output can
Expand Down Expand Up @@ -107,10 +119,33 @@ impl miette::Diagnostic for Error {
Self::UnknownCatalog(d) => Some(Box::new(format_unknown_catalog_help(d))),
Self::UnknownCatalogEntry(d) => Some(Box::new(format_unknown_catalog_entry_help(d))),
Self::BlockedExoticSubdep(d) => Some(Box::new(format_exotic_subdep_help(d))),
Self::TrustDowngrade(d) => Some(Box::new(format_trust_downgrade_help(d))),
Self::TrustCheckMissingTime(d) => Some(Box::new(format_trust_missing_time_help(d))),
}
}
}

fn format_trust_downgrade_help(d: &TrustDowngradeDetails) -> String {
format!(
"a trust downgrade may indicate a supply-chain incident — the publisher's previous releases carried {evidence} but {name}@{ver} does not.\n\
to bypass: pin a version that retains evidence, set `trustPolicy = off` in .npmrc / pnpm-workspace.yaml, \
or add `{name}` (or `{name}@{ver}`) to `trustPolicyExclude`.",
evidence = d.prior_evidence.label(),
name = d.name,
ver = d.picked_version,
)
}

fn format_trust_missing_time_help(d: &MissingTimeDetails) -> String {
format!(
"trustPolicy=no-downgrade compares against per-version publish times in the packument. \
The registry serving {name} omitted `time[{ver}]` — check the registry config in .npmrc, \
or set `trustPolicy = off` to skip the check.",
name = d.name,
ver = d.version,
)
}

/// Build a `NoMatchDetails` snapshot from the task that failed and the
/// packument it was looked up against. Captures importer, parent chain,
/// the original package.json spec (if rewritten by catalog/override/
Expand Down
3 changes: 3 additions & 0 deletions crates/aube-resolver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod peer_context;
pub mod platform;
mod resolve;
mod semver_util;
mod trust;
mod types;

pub use error::{AgeGateDetails, CatalogDetails, Error, ExoticSubdepDetails, NoMatchDetails};
Expand All @@ -17,6 +18,8 @@ pub use peer_context::{
hoist_auto_installed_peers,
};
pub use platform::{SupportedArchitectures, is_supported};
pub use trust::{MissingTimeDetails as MissingTrustTimeDetails, TrustDowngradeDetails};
pub use trust::{TrustEvidence, TrustExcludeParseError, TrustExcludeRules};
pub use types::{
DependencyPolicy, MinimumReleaseAge, PackageExtension, ReadPackageHook, ResolutionMode,
ResolvedPackage, TrustPolicy,
Expand Down
41 changes: 38 additions & 3 deletions crates/aube-resolver/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,20 @@ impl Resolver {
// cheaper abbreviated path stays on the hot path and we save
// one full-packument fetch per distinct package.
let needs_time = (self.resolution_mode == ResolutionMode::TimeBased
|| self.minimum_release_age.is_some())
|| self.minimum_release_age.is_some()
|| self.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
&& !self.registry_supports_time_field;
let minimum_release_age_only =
self.resolution_mode != ResolutionMode::TimeBased && self.minimum_release_age.is_some();
// Gate for the corgi-shortcircuit at L219-235: when the only
// reason we need `time` is `minimumReleaseAge` AND the
// packument's top-level `modified` predates the cutoff, every
// version is old enough so we can skip the full-packument
// fetch. trustPolicy=NoDowngrade adds a second time requirement
// (per-version compare), so the shortcircuit must NOT fire when
// it's on — otherwise the trust check falls through to a
// spurious TrustCheckMissingTime on every version.
let minimum_release_age_only = self.resolution_mode != ResolutionMode::TimeBased
&& self.minimum_release_age.is_some()
&& self.dependency_policy.trust_policy != crate::TrustPolicy::NoDowngrade;
// In-flight packument fetches. The spawned task returns the
// `(name, packument)` tuple so `join_next` gives us back the
// identity of whichever fetch landed next without a side
Expand Down Expand Up @@ -1268,6 +1278,31 @@ impl Resolver {
))));
}
};
// Trust-policy enforcement runs *before* any other
// post-pick processing (mirrors pnpm's placement
// immediately after `pickPackage`). Skip when policy is
// off so the off-by-default case is a single enum
// compare. The check needs the live packument's `time`
// map and all version metadata, both of which are still
// in scope here from L1191.
if self.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade {
crate::trust::check_no_downgrade(
packument,
&picked_ref.version,
picked_ref,
&self.dependency_policy.trust_policy_exclude,
self.dependency_policy.trust_policy_ignore_after,
)
.map_err(|e| match e {
crate::trust::TrustCheckError::Downgrade(d) => {
Error::TrustDowngrade(Box::new(d))
}
crate::trust::TrustCheckError::MissingTime(d) => {
Error::TrustCheckMissingTime(Box::new(d))
}
})?;
}

// Clone the picked metadata into an owned value so we can
// both run the `readPackage` hook (which needs a
// disjoint `&mut self` borrow) and, later, mutate the
Expand Down
Loading
Loading