Rules engine and responses

Part of the Écluse architecture overview.

Rules engine

Deny by default; the boot order decides. Each rule carries a configurable integer precedence. At boot the rule set is arranged once into a single total order (highest precedence first, then rule name ascending as the deterministic tiebreak), and evaluation walks that order and takes the first decisive result. If nothing is decisive, the package is denied. Equal precedence is resolved by name, not by a deny-over-allow priority: the order is the tiebreak, so shuffling the configured set yields the same decision. Built-in deny rules default above allow rules, so "any deny overrides any allow" holds out of the box, but an operator can rank a specific allow above a specific deny (say, to let a trusted internal scope through an install-script deny).

Rules evaluate a single PackageDetails snapshot, the ecosystem-agnostic per-version view an adapter produces (see The internal domain model); a rule never sees registry wire formats, and rule names track the agnostic concept, not one ecosystem's mechanism (the install-time code-execution signal, not npm's hasInstallScript). Where a signal a rule reads is simply absent for an ecosystem, the rule yields no decision, the sensible no-op under deny-by-default, never a configuration error.

A Rule is closed Eq/Show data with no evaluation; evalRule is the single dispatch over it (Ecluse.Core.Rules). Keeping Rule closed is a security boundary: untrusted config can only name a built-in constructor, never supply an evaluator. A rule is pure or effectful by whether it carries a resilience policy, depending only on where its signal lives, so DenyInstallTimeExecution is pure for npm's hasInstallScript but effectful for a RubyGems native extensions signal that appears only inside the .gem.

Evaluation model

Each rule, applied to a PackageDetails, yields a RuleVerdict, a deterministic answer, never a fault:

Under its resilience harness a rule either returns a decided verdict, taken at face value, or the harness synthesises Unavailable when it could obtain no verdict at all (the IO faulted, timed out, or the breaker is open). The engine walks the boot order and credits the winning rule by name; with nothing decisive it collects each non-decisive reason, in boot order, so the denial can explain what was considered. The full verdict and harness vocabulary is in the Ecluse.Core.Rules Haddock; the boot order is logged at start-up (see Configuration → rule policy).

Effectful-rule failure

An effectful rule does IO that can fail or hang. Each carries a short per-attempt timeout with bounded retry and backoff and a per-source circuit breaker: after repeated failures the breaker trips and the rule fast-fails for a cooldown, so a sustained outage neither adds latency to every request nor hammers a down service. The shipped defaults are a 2-second per-attempt timeout, two retries at 100ms then 250ms, and a breaker tripping after 5 consecutive failures and cooling for 30 seconds.

A fault the harness observes becomes Unavailable; a deterministic in-process absence a rule reports as CannotVet (no advisory database loaded) is taken at face value, never retried and never counted towards the breaker, because no retry could change it. Either is governed by the rule's failure alignment:

The blast radius is small: only packages not yet in the private mirror reach this path; already-approved versions serve from the private upstream with no rules. How a fail-closed failure surfaces depends on the request shape: on a packument the version is filtered out like a denied one (no error unless nothing survives); on a concrete artifact it surfaces via the error model as 503 (with Retry-After) when transient, 500 when not. Every fail-closed undecidable result and breaker trip emits an ERROR log and metric.

Applying verdicts to a packument

Evaluation decides a single version, but a metadata request returns a whole packument, so verdicts are applied across it (for the cross-upstream merge, see Registry model → Packument merge).

Because the filtered body differs from upstream's, the proxy computes its own ETag over the filtered body (see Web layer).

Initial rule set

Rule Type Description
AllowIfOlderThan ageSeconds Pure Allows a version published more than ageSeconds ago. Default: 604800 (7 days). Guards against typosquatting and dependency confusion, where attackers race to publish before detection.
AllowIfRemediatesCve Effectful Allows a version a synced advisory names as its exact fixed version, provided no advisory still affects it: the remediation fast lane past the quarantine. Abstains when it cannot confirm a remediation, including before a first advisory sync.
AllowScope scope Pure Unconditionally allows all packages under a given npm scope (e.g. @myorg). Use for internal scopes that bypass public-registry rules.
AllowByIdentity identity Pure Allows a specific package or package@version by exact identity: the allow twin of DenyByIdentity. Ranks above DenyIfCve (an identity pin overrides an advisory deny) but below the install-script and revocation denies.
DenyInstallTimeExecution Pure Denies any version flagged with an install-time code-execution signal (npm's hasInstallScript, a RubyGems native extension, a PyPI sdist), a common arbitrary-code-execution vector. Yields no decision otherwise, and overrides any allow at its higher default precedence.
DenyByIdentity identity Pure A hard deny for a specific package or package@version, at the top precedence: the post-mirror revocation mechanism.
DenyIfCve params Effectful Opt-in. Denies a version a synced advisory records as affected at or above a CVSS minSeverity; an unscored advisory (the npm malware feed carries no score) counts as above every threshold, so it blocks malware too. Ranked below AllowByIdentity; its onUnavailable fails closed by default. See the deny direction.

The default precedence ladder climbs from most-passive to most-decisive:

AllowIfOlderThan (100) < AllowIfRemediatesCve (150) < AllowScope (200) <
DenyIfCve (225) < AllowByIdentity (250) < DenyInstallTimeExecution (300) <
DenyByIdentity (400)

DenyInstallTimeExecution and DenyByIdentity default strictly above every allow, so "any deny overrides any allow" holds for them out of the box. DenyIfCve is the deliberate exception: it sits below AllowByIdentity (225 against 250) so an operator's exact-identity allow overrides an advisory deny, the explicit "I have decided this version must ship" escape hatch, while still sitting above the passive age gate, the remediation lane, and a scope allow-list. An operator may raise a specific allow above a specific deny (or the reverse) with an explicit precedence.

Which rules ship enabled is documented with the default policy: the pure AllowIfOlderThan quarantine (min-age) and the AllowIfRemediatesCve fast lane, which abstains when no advisory database is configured so only the quarantine governs. Every other rule is off by default and opts in by name.

CVE subsystem

The advisory subsystem reads a synced local copy rather than calling an advisory API per evaluation: the CveLookup handle (Ecluse.Core.Cve) reads the synced osv.db SQLite artifact on local disk, never the network, on the hot path. It models an advisory's affected set faithfully: range bounds (inclusive introduced, exclusive fixed or inclusive last_affected), exactly-enumerated versions as points, and a numeric CVSS base score per advisory. Access is acquisition-bracketed per evaluation, so the shadow-swap can retire a superseded artifact the moment no evaluation still reads it. Two rules read it in opposite directions.

AllowIfRemediatesCve, remediation fast-track

A publish-age quarantine would also hold back the security patch that fixes an in-the-wild vulnerability, delaying remediation by exactly the window meant to catch typosquats. AllowIfRemediatesCve removes that tension. For version V of P:

It ranks above the quarantine allow (so a fix is admitted immediately) and below the scope allow-list (so a trusted scope never pays the probe). The fix test is a deliberate exact string match on the advisory's canonical fixed version; a fix published under any other string waits out the quarantine, with AllowByIdentity as the operator's workaround. Range membership is decided in Haskell using the same per-ecosystem ordering as compareVersions, with every unprovable comparison counting as affected, so the lane only opens on evidence.

DenyIfCve, the deny direction

DenyIfCve reads the same lookup to block version V of P when an advisory affects V at or above a configured CVSS minSeverity. It is opt-in and does two jobs against the npm feed. Most of that feed is the malware feed (MAL-* advisories that carry no CVSS score and name the bad version exactly); a smaller share is CVSS-scored CVEs. An unscored advisory counts as above every threshold, so malware is always denied while minSeverity governs the scored CVEs.

Because enabling it on a cold mirror can deny historical versions an existing build depends on, it ships off; operators warm the mirror first (see USAGE → Onboarding DenyIfCve).

Local polling, decoupled ingestion

Rather than parse raw JSON advisory dumps on the proxy (heavy GC pressure and memory spikes), Écluse uses a decoupled pipeline, Écluse Pilot: a standalone service that pulls OSV's per-ecosystem exports, compiles them into a read-only SQLite database (osv.db), and pushes it to a private S3/GCS bucket. advisories.bucket names that bucket; unset, the advisory stack is off.

The proxy runs one supervised sync task per configured mount ecosystem (Ecluse.Runtime.Cve.Sync), each polling the bucket's stable per-ecosystem key for ETag changes at advisories.pollInterval (deliberately more frequent than Pilot's compile interval, since matching them would nearly double the worst-case advisory age). The tasks are independent, so one ecosystem's missing artifact never holds back another's. A newly detected osv.db is downloaded to a temp file, byte-bounded by advisories.maxDatabaseBytes, and, treated as untrusted even behind the bucket's access controls, accepted only after a cheapest-first verification (epoch stamp, integrity scan, the required tables' strict-schema conformance, ecosystem). The accepted file is renamed atomically and shadow-swapped into the read path (Ecluse.Core.Cve.Slot): the swap waits for the displaced generation's readers to drain, so pruning is the kernel's reclamation, never a mistimed delete. A refused artifact is discarded, its ETag remembered, and the last-good generation keeps serving. Readiness waits for each ecosystem's first sync while the listener serves throughout: an absent database only abstains into deny-by-default.

Polling removes the one external dependency that would otherwise sit under the fail-closed gate: an advisory-source outage becomes sync lag, not per-package blocking. Lookups also leave the hot path, since a version is checked only before it is mirrored and served rule-free after.

The artifact contract

The object key is stable per ecosystem and embeds the table-schema epoch, <ecosystem>-osv-schema<N>.db (currently npm-osv-schema3.db), which is what makes ETag polling work. The epoch is a hand-bumped constant shared by the Pilot writer and the proxy reader (Ecluse.Core.Osv.Schema) and stamped inside the artifact as SQLite's user_version; a mismatch keeps the last-good database and alarms. The artifact is immutable and rebuilt from scratch on every compilation, so there are no migrations, only a read-compatibility contract. The epoch moves only for a breaking change; additive changes (a new column or table) do not bump it, because readers select explicit columns. Pilot filters rows to the target ecosystem so an advisory spanning two ecosystems does not leak foreign package rows. Each denial's audit log records the advisory database ETag live at emit (active_advisory_db_etag), deliberately the one live at emit rather than the one the verdict was evaluated against, since a shadow-swap can land mid-request.

Point-in-time gating, a known limitation

CVE gating happens at ingestion: a version is checked once, before it enters the mirror, and served rule-free thereafter, so a CVE disclosed after a version is mirrored is not caught by the gate. The post-ingestion disposition (operator scanning, a hard deny-by-identity revocation, and operator purge, deny-then-purge) is catalogued in the threat model; holding the dataset locally keeps a periodic mirror re-scan straightforward to add later.

Denial responses

When a request is denied (no allow rule matched, or a deny rule fired):