Threat model

One file holds both what Écluse defends against and what it leaves to you: a Saerskriven model, threat-modelling/ecluse.yaml. The site build renders the diagram and the register below from that file on every deploy, so neither can drift from the model. Each number is a lifetime identifier from the model, not a row count, so the sequence keeps its gaps. The model also records the canonical deployment posture, the trust assumptions it rests on, and the operator responsibilities it places out of scope, such as edge access control and storage-layer scanning.

Each threat carries one status:

Security posture explains the security invariants the code upholds, and why.

Data-flow diagram#

High LevelOperator trust zone (VPC / mesh): access edge enforced herePublic internet (untrusted)Public internet (advisory sources)Package client(developer / CI)Public registry(npm, PyPI)Écluse proxyÉcluse mirrorworkerMirror queue (SQS)Registry C: private upstream(pull-through read endpoint)Registry A: publication target(first-party store)Registry B: mirror target(public-derived store)Écluse Pilot(advisoryingestion)1HEPSS feedOSV.devAdvisory store (S3)1MÉcluse Dredgerinstall and publish(caller credential)private read (callercredential forwarded)enqueue mirror job(demand-driven)publish mirrored artifact(mirror worker identity)pull-through (mirrored)OSV advisory exportsEPSS scoresdelete denied versionsdelete retained copiesfrom the private readcacherelay first-party publish(publisher or staticpublication token)publish advisoryartifactssync advisory artifacts(read-only)sync advisory artifacts(read-only)sync advisory artifacts(read-only)pull-through(first-party)receive mirror jobspublic metadata andartifact fetch(anonymous)back-fill artifact fetch(untrusted)

Threat register#

NumberTitleElementsCategorySeverityStatus
1Forwarded caller credentials aggregated in proxy memoryÉcluse proxyInformation disclosure (STRIDE)HighMitigated
2Chokepoint exhaustion via pathological client or upstream inputÉcluse proxyDenial of service (STRIDE)MediumMitigated
3Off-by-default edge auth assumes a sound network boundaryinstall and publish (caller credential)Spoofing (STRIDE)MediumMitigated
4Caller credential leak to the public upstreampublic metadata and artifact fetch (anonymous)Information disclosure (STRIDE)HighMitigated
5SSRF via crafted identifier or upstream-declared dist.tarballpublic metadata and artifact fetch (anonymous)Elevation of privilege (STRIDE)HighMitigated
6Package shadowing via first-party publishrelay first-party publish (publisher or static publication token)Tampering (STRIDE)HighMitigated
7Mirror-write credential is a standing privilege over the trusted storepublish mirrored artifact (mirror worker identity)Elevation of privilege (STRIDE)HighMitigated
8SSRF to the instance-metadata credential endpointÉcluse proxy, Écluse mirror workerElevation of privilege (STRIDE)MediumMitigated
9Cross-client disclosure of a private package via shared cacheÉcluse proxyInformation disclosure (STRIDE)HighMitigated
10Registry collapse erases provenance and per-store policyRegistry B: mirror target (public-derived store)Repudiation (STRIDE)MediumMitigated
11Undetected artifact substitution across upstreamsRegistry C: private upstream (pull-through read endpoint)Tampering (STRIDE)HighMitigated
12Upstream registry forges its own server-asserted metadata (e.g. a backdated publish time)Public registry (npm, PyPI)Tampering (STRIDE)HighAccepted risk
13Mirrored versions remain trusted until eligible pruning succeedsRegistry B: mirror target (public-derived store)Tampering (STRIDE)MediumMitigated
14SSRF via the worker back-fill fetch (a blind sink)back-fill artifact fetch (untrusted)Elevation of privilege (STRIDE)LowMitigated
15Private-upstream aggregation admits the public registry, bypassing the gateRegistry C: private upstream (pull-through read endpoint)Tampering (STRIDE)HighTransferred
16Connect-time reachability/timing oracle for an attacker-controlled allowlisted DNSpublic metadata and artifact fetch (anonymous)Information disclosure (STRIDE)LowAccepted risk
17Pilot container-role privilege escalationÉcluse Pilot (advisory ingestion)Elevation of privilege (STRIDE)HighMitigated
18Proxy compromised via tampered OSV databaseÉcluse proxyTampering (STRIDE)HighMitigated
20Pathological OSV Payload (DoS)Écluse Pilot (advisory ingestion)Denial of service (STRIDE)MediumMitigated
21Massive Purge DoSÉcluse DredgerDenial of service (STRIDE)MediumMitigated
22Mirror-write credential can be sent to a misconfigured registry targetpublish mirrored artifact (mirror worker identity)Information disclosure (STRIDE)MediumMitigated
23Package-name spoofing via invisible charactersinstall and publish (caller credential)Spoofing (STRIDE)MediumMitigated
24Package-name typosquatting within the permitted character setinstall and publish (caller credential)Spoofing (STRIDE)MediumAccepted risk
25Dredger inappropriately purges valid packagesÉcluse DredgerDenial of service (STRIDE)MediumMitigated
26Dredger container-role privilege escalationÉcluse DredgerElevation of privilege (STRIDE)HighMitigated
28First-party data loss from collapsed registriesÉcluse DredgerDenial of service (STRIDE)HighMitigated
29Publisher credential relayed to a misdirected publication targetrelay first-party publish (publisher or static publication token)Information disclosure (STRIDE)MediumMitigated
101Oracle Blackout / Supply Chain DoS via OSV.dev compromiseÉcluse Pilot (advisory ingestion)Spoofing (STRIDE)HighAccepted risk
102Accidental permanent deletion of registry dataÉcluse DredgerElevation of privilege (STRIDE)CriticalMitigated
103Dependency confusion through a first-party namespace on the public upstreampublic metadata and artifact fetch (anonymous)Spoofing (STRIDE)HighMitigated
104Configured deletion consent stays valid when the store it names changesÉcluse DredgerElevation of privilege (STRIDE)MediumAccepted risk
105Store-supplied path components steer a credential-bearing deletedelete denied versionsElevation of privilege (STRIDE)MediumMitigated
106Cold-start advisory availability depends on S3Advisory store (S3)Denial of service (STRIDE)MediumMitigated
107Downstream cleanup gains authority over unrelated private versionsdelete retained copies from the private read cacheElevation of privilege (STRIDE)HighMitigated
108Partial downstream cleanup leaves revoked content availabledelete retained copies from the private read cacheTampering (STRIDE)HighMitigated
109An outdated Dredger policy deletes the only remaining package copyÉcluse DredgerDenial of service (STRIDE)MediumAccepted risk
110An admitted mirror transfer can finish after an advisory changespublish mirrored artifact (mirror worker identity)Tampering (STRIDE)LowAccepted risk
111Incorrect EPSS source scores become authoritative policy evidenceÉcluse Pilot (advisory ingestion)Spoofing (STRIDE)HighOpen
112Stale or incomplete advisory snapshots remain usable policy evidenceAdvisory store (S3)Tampering (STRIDE)MediumOpen
113Advisory source URL credentials enter artifacts and sync logsÉcluse Pilot (advisory ingestion)Information disclosure (STRIDE)MediumMitigated
114Advisory package identity mismatch bypasses policyÉcluse proxyTampering (STRIDE)HighMitigated
115Withdrawn OSV records remain active policy evidenceÉcluse Pilot (advisory ingestion)Tampering (STRIDE)HighMitigated
116Rejected artifact entries return through filename-based assemblyÉcluse proxyTampering (STRIDE)HighMitigated
118Crafted string collisions make one metadata read quadraticÉcluse proxy, Écluse DredgerDenial of service (STRIDE)LowMitigated

Assumptions that apply to the model

  • Valid

    The operator's access edge decides who may reach Écluse: a gateway, a service mesh, or a network policy. The edge restricts north-south and east-west traffic, so a compromised neighbour cannot reach a pod directly around an ingress-only allow-list. An access edge outside Écluse does not replace the edge token that a static publication token requires.

  • Valid

    Scanning the stores that hold packages is the operator's responsibility and is outside this model. Écluse decides what enters the mirror and what the proxy serves. It does not scan stored artifacts.

Threat 1: Forwarded caller credentials aggregated in proxy memory

  • Elements: Écluse proxy
  • Category: Information disclosure (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

Under the canonical passthrough strategy the proxy transiently holds every caller's own CodeArtifact bearer token in process memory while it relays reads and publishes. One proxy compromise therefore harvests the credentials of all callers in transit, not one. A heap or memory dump, a log-field leak, and a malicious dependency in Écluse's own supply chain all reach that result. Passthrough spreads credential exposure across every user. A service identity would instead concentrate it in one short-lived token.

Mitigations

  • Implemented Credentials are redacted and request-scoped

    Écluse holds a token in the Secret type, whose Show instance renders a fixed placeholder, so a token never reaches a log field. A token lives for one request, and the code unwraps it only to attach the bearer to an outbound request. Neither the data-plane nor the WAI span instrumentation records an Authorization header, although the WAI layer records benign headers such as User-Agent. A regression test keeps the Authorization header off the server span.

  • Implemented Attested, reproducible runtime image

    A garbage-collected runtime cannot erase a token from the heap promptly, so the compensating control is the hardening of Écluse's own runtime and supply chain. The release builds a reproducible image with build provenance and SBOM attestations. grype and osv-scanner scan the image closure and report findings to code scanning for triage. The scans report and do not gate a change.

Assumptions

None recorded.

Threat 2: Chokepoint exhaustion via pathological client or upstream input

  • Elements: Écluse proxy
  • Category: Denial of service (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

Écluse is a mandatory chokepoint, so degrading its availability is itself a supply-chain attack. Builds fail, or operators are tempted to bypass the gate. A hostile or compromised upstream registry, or a pathological public package, could return an oversized, version-flooded, or deeply nested packument, and parsing it and evaluating the rules per version could exhaust CPU or memory. A caller who can reach the listener also controls the PyPI distribution filenames parsed during route selection, before edge authentication and metadata admission. Repeated prefix construction and canonicalisation on separator-heavy filenames can cause quadratic allocation and CPU work, and upstream Simple-index filenames reach the same parser. An upstream that stops sending a response body partway, or trickles it, holds the fetch, and every request coalesced onto it keeps its admission slot while it waits.

Mitigations

  • Implemented Bounded PyPI filename parsing

    PyPI coordinate parsing compares disjoint project-name chunks in bounded passes and keeps the filename grammar without a new length cap. Distribution routing and incremental Simple-index extraction use the same parser. Tests at both entry points assert subquadratic allocation growth for malformed filenames. Route selection precedes authentication and metadata admission, so the operator edge bounds total routing demand.

  • Implemented Fail-closed document limits

    Fail-closed limits still bound metadata body bytes, version count, artifact count and retained structure depth (ECLUSE_LIMITS__MAX_RESPONSE_BYTES, ECLUSE_LIMITS__MAX_VERSION_COUNT, ECLUSE_LIMITS__MAX_ARTIFACT_COUNT, ECLUSE_LIMITS__MAX_NESTING_DEPTH). The reader counts decompressed bytes and stops an oversized response. Dredger reads store package documents under the same body bound. npm and PyPI extract supported fields by walking the tokens of the maintained json-stream lexer, checking structural counts during extraction instead of projecting a complete source document first. Skipped fields do not become decoded values. A release or file the projection drops, and each repeat of a member within an object, never enters the read's table of shared strings, and a repeated member is dropped as it arrives. Extraction does not establish whole-document JSON validity. Supported fields still materialise, and listings retain useful release and artifact results. Body limits and structural limits remain separate from memory admission. Neither streaming nor the response cap proves a bound on the resulting heap. Advisory-backed rules still perform work for each evaluated version.

  • Implemented Single-flight requests, the upstream progress floor, and request timeout

    Single-flight joins eligible concurrent reads or renders for the same key onto one active computation, independently of retention. The local provider retains no full metadata, but active full reads still coalesce. Selected-version and assembled-response retention remain available through that provider. A per-request timeout limits each request. Every transfer over a registry protocol, the artifact stream relayed to a client included, keeps a progress floor of request and response body bytes together, at least limits.minProgressBytes (1 MiB by default) within each limits.progressWindow seconds (10 by default) spent waiting on the upstream. A transfer below the floor, a silent one included, fails with the transport timeout, and its connection closes rather than returning to the pool. Each upstream exchange of a served request also ends at the request timeout less a fixed 10-second margin, so a slow fetch fails before the request timeout, and the requests coalesced onto it receive the same fault and release their admission. The mirror worker and the Dredger have no such cap. The timeout is a transport fault like any other, and a private read that fails this way for a name that is not first-party falls back to the rule-gated public document, as any private transport fault does. Distinct keys do not coalesce, and useful results still occupy memory until their consumers release them.

  • Implemented Memory-charged serve admission and bounded retention

    Listings and public artifact metadata decisions pass a memory gate and then a CPU gate. The memory gate charges an entry step against one budget before the request takes a CPU slot. A listing then pays for each decompressed chunk of its full reads before the parser sees it, and for its response before it builds it. The response pays on the larger of two estimates, one from the largest document the listing merges and one from the base document whose top-level fields it keeps, each adding the other document's share for the versions it lacks, so overlapping private and public documents pay for about one. The output charge comes from single documents and realistic merges, and the residency tier holds heavier private documents within it. The tier requires each registered ecosystem adapter to have captures, retained-heap envelopes and peak limits. For packages of at least 1 MiB the per-byte charges sit above the residency tier's measured peaks. A smaller package can peak above its per-byte charge, and that tier holds each capture's read and render under what the meter holds for it, at least the entry step. A selected read, a retained hit and a request that joins another's fetch or render pay only for what they hold themselves. A started read that runs out of budget pauses instead of failing. Only one request at a time may run past the budget, until it ends, together with the shared work it waits on, so the budget holds within about one request's worth and the ingest ceiling bounds that request. The boot sets the budget to a quarter of the heap the nursery leaves, less the cache, the queue tenants and the idle process. A sampler reads the collector and the cgroup ten times a second. It halves the budget, at most once a second, when the collector takes more than half the CPU, when live data nears the heap's overflow point or when the cgroup nears its limit. After each major collection it measures the live data outside the charges, so that charges and that remainder stay under a third of the heap the nursery leaves, with room under the overflow point for the largest recent request. The heap ceiling sits below the cgroup limit with room for native and kernel memory, so heap exhaustion ends in a heap-overflow exit rather than a kernel kill. The charges are a model of held memory, not exact heap accounting. Both gates release before artifact relay. A separate byte-weighted gate admits publish bodies. One provider owns metadata retention, and the local provider never retains full metadata. Selected-version and assembled stores charge accounted weights against their retention budget. An entry that cannot fit is served without retention or eviction. Assembled reuse never skips private fetches or their per-request authorisation. Caller credentials never enter shared metadata retention.

Assumptions

  • Valid

    The operator's edge limits request volume and concurrency for proxy callers and Pilot's advisory fetches. Metadata admission limits the concurrency and the live data of serve metadata work, not total process memory or work across all roles. Route selection, including PyPI filename parsing, runs before edge authentication and metadata admission. The edge therefore bounds total routing work and demand from distinct request keys.

Threat 3: Off-by-default edge auth assumes a sound network boundary

  • Elements: install and publish (caller credential)
  • Category: Spoofing (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

The edge token, server.authToken (ECLUSE_SERVER__AUTH_TOKEN), is off by default. If the operator's access edge fails, an unauthenticated caller can drive the proxy. The east-west case is the notable one: a compromised neighbour reaches the pod directly and bypasses an ingress-only IP allow-list.

Mitigations

  • Implemented Optional edge token

    When server.authToken is set, clients must present it, and the publish gate refuses a missing or wrong edge credential before any relay. The token is off by default.

  • Implemented Static publication token behind the edge token

    A static publication-target token (mounts.<eco>.publicationTarget.<tag>.token) requires server.authToken, and the boot refuses a configuration with one and not the other. After the relay authenticates the edge token, it replaces that token with the static token, so the edge secret does not reach the publication target. Without a static token, a closed-edge publish forwards the edge token unchanged, and an open-edge publish forwards the caller's own credential.

  • Implemented Per-caller credential passthrough

    The read path never substitutes a credential of Écluse's own. It forwards the caller's credential to the private upstream and strips caller credentials from public fetches, which it makes anonymously. The publish path sends credentials only to the operator-declared publication target, and Écluse mints no publication token.

Assumptions

  • Valid

    The operator's access edge decides who may reach Écluse: a gateway, a service mesh, or a network policy. The edge restricts north-south and east-west traffic, so a compromised neighbour cannot reach a pod directly around an ingress-only allow-list. An access edge outside Écluse does not replace the edge token that a static publication token requires.

Threat 4: Caller credential leak to the public upstream

  • Elements: public metadata and artifact fetch (anonymous)
  • Category: Information disclosure (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

The upstream registry is attacker-influenceable and must never receive a caller's credential. A failure to strip the caller token on the public fetch would disclose a live CodeArtifact token to public npm. So would following a cross-host 3xx with the bearer still attached, to an attacker-chosen redirect target, over the unguarded private manager.

Mitigations

  • Implemented Per-caller credential passthrough

    The read path never substitutes a credential of Écluse's own. It forwards the caller's credential to the private upstream and strips caller credentials from public fetches, which it makes anonymously. The publish path sends credentials only to the operator-declared publication target, and Écluse mints no publication token.

  • Implemented No redirects on any data-plane request

    The shared parse entry pins redirectCount=0 on every request it returns, and the mirror-write transport pins it again on every request an adapter's codec forms, whether or not the codec routes through credential attachment. No data-plane request, credential-bearing write, or maintenance delete follows a redirect, so no hop can move a credential after the target check, escape the egress allow-list, or downgrade the scheme.

  • Implemented Endpoint collision refusal at boot

    The boot and check-config compare declared endpoints before any role starts, and each refusal names the keys involved. Registry identity folds the authority to lower case, applies the default port, ignores a trailing slash, and compares the path exactly, so distinct repositories on one host stay distinct, and so do the repositories of one CodeArtifact domain and a repository's per-format endpoints. Every role refuses a private upstream that is the same repository as its own mount's public upstream, a mirror target or publication target on the host of any mount's public upstream, a publication target equal to another mount's private upstream, mirror target, or publication target, and two endpoints that name one registry under different store tags, including a second tag added by an environment layer. The Dredger, preview included, also refuses a mirror target equal to any mount's private upstream or to its own mount's publication target. It refuses a publication target equal to its own mount's private upstream. Its deletions there would reach first-party packages that exist only in the publication target. The proxy and the mirror worker boot on the two mirror-target pairs and warn once for each. They boot on a publication target equal to their own mount's private upstream and say nothing, because neither role deletes. One combinator maps each collision to the outcome of the booting role, so a refusal, a warning, and silence come from one rule. The publish relay takes its target as a VettedPublication, whose constructor the validation module does not export, so every target the relay receives comes from this boot check. The check adds no restriction for DNS aliases.

  • Implemented https-only egress with certificate validation

    Every outbound registry URL passes through mkRegistryUrl, which rejects any scheme other than https, and a configured endpoint that is not https fails the boot. The trusted private origin, the publication target, the mirror target, the store a delete goes to, and the back-fill fetch all meet this rule on the validating-TLS data-plane manager. Certificate validation authenticates the dialled host. A name steered to an internal or rebound address cannot present a CA-trusted certificate for the requested host, so the connection fails at the handshake before any request body leaves. This closes the resolve-to-internal and DNS-rebinding classes without a resolved-IP recheck.

Assumptions

None recorded.

Threat 5: SSRF via crafted identifier or upstream-declared dist.tarball

  • Elements: public metadata and artifact fetch (anonymous)
  • Category: Elevation of privilege (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

Écluse builds outbound URLs from client-supplied package identifiers and upstream-declared artifact locations. A traversal, encoded-slash, or absolute-URL name could steer a fetch to an unintended target such as cloud metadata or the private network. So could a dist.tarball that points at an internal or attacker-chosen host.

Mitigations

  • Implemented No redirects on any data-plane request

    The shared parse entry pins redirectCount=0 on every request it returns, and the mirror-write transport pins it again on every request an adapter's codec forms, whether or not the codec routes through credential attachment. No data-plane request, credential-bearing write, or maintenance delete follows a redirect, so no hop can move a credential after the target check, escape the egress allow-list, or downgrade the scheme.

  • Implemented Canonical identifiers, encoded on build

    Écluse canonicalises a package identifier and percent-encodes it when it builds an outbound URL, so a traversal, encoded-slash, or absolute-URL name does not change the request target.

  • Implemented Outbound host allow-list

    Écluse enforces an outbound host and port allow-list where it builds each request URL. A back-fill location passed the same allow-list at serve time, before its job was enqueued. The allow-list bounds which names can be aimed inward. It does not remove connect-time timing.

  • Implemented https-only egress with certificate validation

    Every outbound registry URL passes through mkRegistryUrl, which rejects any scheme other than https, and a configured endpoint that is not https fails the boot. The trusted private origin, the publication target, the mirror target, the store a delete goes to, and the back-fill fetch all meet this rule on the validating-TLS data-plane manager. Certificate validation authenticates the dialled host. A name steered to an internal or rebound address cannot present a CA-trusted certificate for the requested host, so the connection fails at the handshake before any request body leaves. This closes the resolve-to-internal and DNS-rebinding classes without a resolved-IP recheck.

  • Implemented Same-authority artifact locations

    A disallow-by-default same-authority policy applies to every upstream-declared artifact location, matched on host and port, with the hosts an ecosystem serves artifact bytes from as its one equivalence. Écluse upgrades a legacy http location to https on the same host, and drops and records a file whose scheme or authority the policy refuses. The projection and the download gate read one definition of the check, so a refused file is absent from the served listing, and a release drops when none of its files survive.

  • Implemented Literal internal-range block

    As defence in depth, the artifact-host gate refuses literal addresses in a fixed set of internal ranges. ECLUSE_EGRESS__ADDITIONAL_BLOCKED_RANGES adds internal space the module cannot know in advance. The setting only widens the set, and a malformed entry fails the boot.

Assumptions

None recorded.

Threat 6: Package shadowing via first-party publish

  • Elements: relay first-party publish (publisher or static publication token)
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

A publish reaches the publication target with the caller's credential, or with a static publication token after edge authentication. Every edge-token holder can publish anything the static token permits within the configured first-party scopes. The packument merge serves private versions as trusted, winning collisions over public ones. A compromised CI system or an insider publisher could clear the first-party check, or slip past it when the name the check authorises differs from the name the relay writes. They could then publish a name that the merge serves as a trusted version over the public package. That is a dependency-confusion path through Écluse's own trust model.

Mitigations

  • Implemented Static publication token behind the edge token

    A static publication-target token (mounts.<eco>.publicationTarget.<tag>.token) requires server.authToken, and the boot refuses a configuration with one and not the other. After the relay authenticates the edge token, it replaces that token with the static token, so the edge secret does not reach the publication target. Without a static token, a closed-edge publish forwards the edge token unchanged, and an open-edge publish forwards the caller's own credential.

  • Implemented First-party publish guard

    A mount's firstParty declaration, for npm a list of scopes, refuses a publish of any name outside the namespaces the deployment owns, before any upstream write. The scope match is exact on the parsed namespace, so @acme-evil does not satisfy @acme. Écluse checks that the publish document's declared name, its _id, and every per-version name equal the scope-guarded name in the URL path, then builds the write URL from that canonical name. The guarded name, the written name, and the merge collision key are therefore one identity.

Assumptions

  • Valid

    The operator's access edge decides who may reach Écluse: a gateway, a service mesh, or a network policy. The edge restricts north-south and east-west traffic, so a compromised neighbour cannot reach a pod directly around an ingress-only allow-list. An access edge outside Écluse does not replace the edge token that a static publication token requires.

  • Valid

    The operator gives each role its own identity with the least privilege the role needs, and Écluse cannot check these grants. Under codeArtifact the mirror worker's role policy allows reads of the mirror repository and package publication only, so the policy sets the scope of the minted token. Under registry and verdaccio the worker's static token belongs to a dedicated user whose package rights cover the mirror store only. This scoping is defence in depth behind Écluse's endpoint checks. The Dredger's delete grant covers the mirror repository and, where the operator enables cleanup of the private read repository, package-scoped deletion there, with its read and consent permissions granted separately and no permission over the publication target. The Dredger guide documents these permissions. A role-minted credential is preferred over a static secret, and on a store with no access control of its own the Dredger's account is a dedicated user whose package rights cover the mirror store only. Pilot holds s3:PutObject on the advisory prefix, the proxy holds s3:GetObject only, and the advisory bucket is private. The publisher's credential for the publication target is least privilege too.

  • Valid

    A publisher who holds publish rights inside a declared first-party namespace is trusted inside that namespace. Shadowing inside a declared scope, and the precedence of allow rules, are the operator's risk.

Threat 7: Mirror-write credential is a standing privilege over the trusted store

  • Elements: publish mirrored artifact (mirror worker identity)
  • Category: Elevation of privilege (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

The mirror worker's role identity is the Écluse identity meant to write the mirror store (Registry B), which feeds the trusted read path. A worker compromise, or any bypass of the admission gate, could write attacker-chosen bytes into the trusted store and poison future reads. A mirror job is unauthenticated data that directs the worker to fetch and publish, so whoever can send to the queue can direct that write. On a store whose only control plane is the ecosystem protocol, the same credential also deletes versions, so a worker compromise can empty the mirror as well as poison it. What bounds the poisoning of future reads is the soundness of the admission gate, the role's blast radius, and queue access control, not the integrity check.

Mitigations

  • Implemented No redirects on any data-plane request

    The shared parse entry pins redirectCount=0 on every request it returns, and the mirror-write transport pins it again on every request an adapter's codec forms, whether or not the codec routes through credential attachment. No data-plane request, credential-bearing write, or maintenance delete follows a redirect, so no hop can move a credential after the target check, escape the egress allow-list, or downgrade the scheme.

  • Implemented Store tag binds credential to endpoint

    An endpoint declares its store as a tag, and the load validates the URL against that tag, so the credential and the endpoint are one declaration. Under codeArtifact, Écluse mints the write token from the container or task role, parses the mint identity from the target's own host, and admits no static token. The token carries the role's own permissions, and CodeArtifact caps its life at 12 hours. Under registry and verdaccio, the tag requires the operator's static token and mints nothing. The schema refuses a token under the minting tag and a missing token under the others, so the configuration cannot state a divergent pair. Validation refuses a codeArtifact URL whose host is not a CodeArtifact endpoint, and a mirror target whose path is not the repository endpoint for the mount's own ecosystem, so a token minted for one domain reaches no other. Each refusal names the key.

  • Implemented Serve-only mounts carry no write surface

    A mount with no mirrorTarget holds and mints no write credential and enqueues no back-fill job. When no mount declares a mirror target, the process builds no mirror queue and starts no worker. When some mounts mirror and others do not, the process builds the queue and the worker for the mirroring mounts only.

  • Implemented Worker forwards bytes unparsed

    The mirror worker hashes the fetched bytes and forwards them unchanged, with no decompression and no tarball parsing, so a malicious artifact has little code-execution surface in the worker.

  • Implemented Worker integrity verification

    The worker verifies the fetched bytes against the upstream's dist.integrity before it publishes them, and it never returns them to a caller. The check fails closed when the strongest digest present uses an algorithm it cannot compute, and it never falls back to a weaker digest. An internal or metadata response cannot match the asserted digest, so such a job fails and drops. The check proves that the bytes match the upstream's assertion. It catches tampering in transit and downgrade, not a hostile upstream or a worker compromise, which the admission gate, the role's grant, and queue access control bound.

Assumptions

  • Valid

    The operator gives each role its own identity with the least privilege the role needs, and Écluse cannot check these grants. Under codeArtifact the mirror worker's role policy allows reads of the mirror repository and package publication only, so the policy sets the scope of the minted token. Under registry and verdaccio the worker's static token belongs to a dedicated user whose package rights cover the mirror store only. This scoping is defence in depth behind Écluse's endpoint checks. The Dredger's delete grant covers the mirror repository and, where the operator enables cleanup of the private read repository, package-scoped deletion there, with its read and consent permissions granted separately and no permission over the publication target. The Dredger guide documents these permissions. A role-minted credential is preferred over a static secret, and on a store with no access control of its own the Dredger's account is a dedicated user whose package rights cover the mirror store only. Pilot holds s3:PutObject on the advisory prefix, the proxy holds s3:GetObject only, and the advisory bucket is private. The publisher's credential for the publication target is least privilege too.

  • Valid

    The mirror queue sits inside the operator's trust boundary and is isolated at the infrastructure level. A job is unauthenticated data that tells the worker to fetch and publish, so the right to send a job is the right to write the trusted store. Écluse relies on the queue's access control for message authenticity, not on signatures, which is the usual pattern for an internal queue with one producer and one consumer. The queue grant lets the serve role send and lets the worker receive and acknowledge. With that grant in place the admission gate is the one sender, so the trusted store holds what the gate admits.

Threat 8: SSRF to the instance-metadata credential endpoint

  • Elements: Écluse proxy, Écluse mirror worker
  • Category: Elevation of privilege (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

Container-role token minting must reach the instance-metadata endpoint (169.254.169.254) and STS. An SSRF that reached metadata could mint the worker's CodeArtifact credential.

Mitigations

  • Implemented No data-plane path to instance metadata

    Écluse follows an internal-resolving location only on the trusted private origin, never on a target a client or an upstream can influence, so client and upstream input does not steer the data plane at the metadata endpoint. Token minting uses amazonka's own HTTP client, separate from the guarded data-plane manager.

Assumptions

  • Valid

    The operator requires IMDSv2 and sets the metadata response hop limit to 1 on hosts that run Écluse. This is defence in depth behind Écluse's own refusal to follow internal locations. The operator does not block the metadata endpoint outright, because role token minting needs it.

Threat 9: Cross-client disclosure of a private package via shared cache

  • Elements: Écluse proxy
  • Category: Information disclosure (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

A cache key carries no credential dimension. If the cache held a private-origin document, one caller could warm an entry and a second, differently authorised caller could receive it. That second caller receives the document without the upstream ever authorising their own request.

Mitigations

  • Implemented Private origin bypasses the shared cache

    The composition wires the private-origin path to an uncached metadata fetch, and wires the shared cache to the anonymous, gated public origin. Écluse re-consults the private origin on every request with the caller's own forwarded token, so the shared cache holds no private document.

Assumptions

None recorded.

Threat 10: Registry collapse erases provenance and per-store policy

  • Elements: Registry B: mirror target (public-derived store)
  • Category: Repudiation (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

Écluse supports collapsing its internal registry roles onto as few as one store. The recommended topology keeps the first-party store (A) and the public-derived mirror store (B) separate and unions them into the pull-through read endpoint (C) at the registry level. The degenerate floor is a mirror target set equal to the private upstream. Collapse loses the physical separation between first-party and public-derived inventory, so the operator can no longer apply distinct storage-level rule sets or scanning per provenance. Collapse also muddies incident scoping after a disclosure ("which mirrored public packages did we hold?"), which weakens a response that should be arithmetic, not forensics.

Mitigations

  • Implemented Endpoint collision refusal at boot

    The boot and check-config compare declared endpoints before any role starts, and each refusal names the keys involved. Registry identity folds the authority to lower case, applies the default port, ignores a trailing slash, and compares the path exactly, so distinct repositories on one host stay distinct, and so do the repositories of one CodeArtifact domain and a repository's per-format endpoints. Every role refuses a private upstream that is the same repository as its own mount's public upstream, a mirror target or publication target on the host of any mount's public upstream, a publication target equal to another mount's private upstream, mirror target, or publication target, and two endpoints that name one registry under different store tags, including a second tag added by an environment layer. The Dredger, preview included, also refuses a mirror target equal to any mount's private upstream or to its own mount's publication target. It refuses a publication target equal to its own mount's private upstream. Its deletions there would reach first-party packages that exist only in the publication target. The proxy and the mirror worker boot on the two mirror-target pairs and warn once for each. They boot on a publication target equal to their own mount's private upstream and say nothing, because neither role deletes. One combinator maps each collision to the outcome of the booting role, so a refusal, a warning, and silence come from one rule. The publish relay takes its target as a VettedPublication, whose constructor the validation module does not export, so every target the relay receives comes from this boot check. The check adds no restriction for DNS aliases.

Assumptions

  • Valid

    Scanning the stores that hold packages is the operator's responsibility and is outside this model. Écluse decides what enters the mirror and what the proxy serves. It does not scan stored artifacts.

  • Valid

    The deployment guide recommends three registries, each governed on its own: a first-party store, a public-derived mirror store, and a pull-through read endpoint that unions the other two at the registry level. Écluse supports collapsing these roles onto fewer stores, down to one, and the guide discourages it. An operator who collapses them gives up the separation of first-party and public-derived inventory, per-store policy, and the Dredger's automated pruning, and accepts that residual against their own threat tolerance. The collapse weakens auditability and defence in depth. It does not remove the admission gate.

Threat 11: Undetected artifact substitution across upstreams

  • Elements: Registry C: private upstream (pull-through read endpoint)
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

When a version exists on both the private and the public origin, a substituted artifact could pass undetected and reach the client as the trusted copy. A weak-only or absent digest could hide the substitution, and so could a flaw in the divergence key. A backend that masks an authentication failure as a 404 can also send a read on to the public origin, because a 404 does not prove that authentication succeeded.

Mitigations

  • Implemented Integrity floors

    Public admission requires the configured integrity floor, which cannot go below SHA-256. Private metadata must meet the trusted integrity floor, which the operator can loosen. Invalid, absent, unsupported, or below-floor evidence does not bypass admission to reach the divergence comparison.

  • Implemented Cross-origin divergence warning

    When a version exists on both origins, Écluse compares the digests in their shared algorithms and logs a conflict at WARNING with both digests, and it increments a bounded-cardinality metric. The private version and its tags stay listed, and private artifact GET, HEAD, and conditional requests keep private preference. Conflict evidence that passed package identity, artifact-location, and integrity admission survives a public rule denial: when a named rule denies the public copy and both digests are fixed, the warning and the metric still fire, and the denied public entries stay out of served metadata. The load refuses the listing-only fail-closed divergence setting with migration guidance. A single-origin mount has no cross-origin comparison.

  • Implemented Private refusal stops the read

    A 401 or 403 from the private upstream stops a metadata or artifact read with a local 403 before Écluse reads the error body, even when public content would pass admission, so no public copy substitutes. A genuine private 404 still falls back to the public origin for a package that is not first-party.

Assumptions

  • Valid

    Écluse trusts the private upstream to hold content it admitted before or first-party content, and a private read does not repeat public admission. When the private and public copies disagree, the disagreement proves neither copy correct, and a damaged private copy can stay served. Écluse keeps no persistent conflict state, does not revalidate the public copy on each read, and does not delete on divergence alone.

Threat 12: Upstream registry forges its own server-asserted metadata (e.g. a backdated publish time)

  • Elements: Public registry (npm, PyPI)
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Accepted risk
  • Flags: None

Description

Écluse's quarantine and integrity reasoning consume fields the upstream registry asserts, notably the per-version publish time and server-side integrity. A registry that asserted forged values could admit content the age and integrity gates would otherwise hold back. A backdated time defeats the quarantine, and a manufactured digest defeats the integrity check. This threat concerns the fields the registry itself asserts, not the tarball contents or the author-supplied fields, which the rules engine and the integrity floor gate.

Mitigations

None recorded.

Assumptions

  • Valid

    The primary registries stamp the publish time and the server-side integrity fields themselves. A publisher cannot forge the publish time, because it is not part of the publish document. Écluse reads registry metadata, so it trusts the registry operator's honesty, and a hostile registry operator is an adversary this model cannot counter, the same class as a malicious npm. A forged publish time would defeat the quarantine. Écluse does not anchor age to its own first observation of a version, because that anchor needs durable state shared across replicas, which conflicts with Écluse's network-broker design, and it would narrow only a surface that already sits outside what Écluse can treat. The central public registries, npmjs and PyPI among them, are infrastructure every consumer already trusts, and trusting their server-stamped timestamps is the practical choice. The owner accepts this residual risk.

Threat 13: Mirrored versions remain trusted until eligible pruning succeeds

  • Elements: Registry B: mirror target (public-derived store)
  • Category: Tampering (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

Private reads trust previously mirrored versions without repeating public admission. Later advisory changes, removal of an allow, or an upstream yank do not themselves remove those copies. An advisory rule set to onUnavailable: skip can let another allow mirror a version during an advisory outage, and recovery alone does not revoke that copy. A deny limited to admission with appliesTo: [admission] refuses new requests and never removes the stored versions it refuses, so private reads keep serving them. The opt-in DenyIfYanked and DenyIfDeprecated rules read typed availability flags without copying or quoting upstream notice text. A PyPI release is yanked only when every file is yanked. Deprecation can cover whole packages and release lines. Revocation reads stored metadata, so a later public flag change does not itself change a stored copy. npm blocks DenyIfYanked and PyPI blocks DenyIfDeprecated at boot because the adapters do not supply those signals. CodeArtifact can retain a serving copy in the private read repository (C) after the version leaves the mirror (B). Without working, authorised pruning, retained content can stay served indefinitely, and a serve-only mount can still read trusted private copies without a mirror target of its own.

Mitigations

  • Implemented Worker integrity verification

    The worker verifies the fetched bytes against the upstream's dist.integrity before it publishes them, and it never returns them to a caller. The check fails closed when the strongest digest present uses an algorithm it cannot compute, and it never falls back to a weaker digest. An internal or metadata response cannot match the asserted digest, so such a job fails and drops. The check proves that the bytes match the upstream's assertion. It catches tampering in transit and downgrade, not a hostile upstream or a worker compromise, which the admission gate, the role's grant, and queue access control bound.

  • Implemented Worker re-evaluation before mirroring

    Public admission applies the configured rules before a version is queued, and the worker applies them again, with its current first-party predicate, before it downloads the artifact. A winning named deny stops new admission when its evidence is available, and the worker drops a queued job for a first-party name.

  • Implemented Deletion only on a named decisive deny

    The Dredger deletes a version only when a named rule denies it decisively. It evaluates the mount's rules that apply at revocation, which the composition root selects in one place, and it never reads a rule limited to admission. The load admits that limit on a deny alone and refuses a rule that applies at revocation alone, so on the same evidence the Dredger never deletes a version the gate admits. Deny-by-default and unvettable results keep the version. Identity-only evidence, a listed version with no readable metadata, goes to that same rule set: any rule that decides from name and version can condemn the version, such as a bare-name DenyByIdentity, DenyIfCve, or DenyIfEpss, and a rule that needs a fact the evidence lacks keeps the version. Rule precedence and every deletion guard still apply. If removing an allow exposes a winning deny that applies at revocation, normal pruning applies, and later cycles reconcile eligible late publications under current evidence.

  • Implemented Maximum advisory age

    Consumers refuse advisory evidence older than the maximum advisory age, measured from Pilot's last push. An explicit advisories.maxAgeSeconds overrides the default, which is the shortest period the quarantine holds a version on that mount, minus 24 hours and never under 3 days, or 3 days when the mount has no quarantine rule. A seven-day hold in the quarantine gives six days, so a failure surfaces before the next cohort leaves the quarantine. Past the maximum age, the CVE and EPSS deny paths refuse even under onUnavailable: skip, and the maximum age applies separately from any rule's skip setting. An undated artifact refuses, and the Dredger withholds advisory-based deletions.

  • Implemented Reconcile retained copies across the mirror and the private read repository

    The Dredger derives cleanup candidates from the actual inventories of the mirror (B) and the private read repository (C), and it repeats both scans after a failure or a restart. It keeps each location's presence on its own. A copy left only in C stays discoverable after B removal, and a version only in B stays discoverable before C retains it. Before every attempt it reads both inventories again, rechecks the target's own consent, and evaluates each candidate against current policy. A candidate stands only while the target's own entry for it reads the same across those reads. A cache candidate also needs the mirror's entry to read the same. It deletes a copy from C only once B no longer serves a denied copy of that version. It records each target's outcome and retries transient failures with bounded backoff. Scheduling or audit records do not replace repository state and do not authorise a stale decision. The Dredger deletes only a version a named rule decisively denies. It skips first-party names. The Dredger checks the resulting private-read state and reports incomplete cleanup. Once roles agree and older writes finish, repeated scans reconcile late copies.

  • Implemented Outage reporting and skipped-check evidence

    Each admission carries the configured checks it did not benefit from: a check that ran and could not vet under onUnavailable: skip, kept apart from a check a higher-precedence allow pre-empted, so no consumer reads an unreached check as passed. The public artifact gate logs a skipped check with the package, version, rule, and cause, once per package, version, and skipped rule set for the life of the advisory outage, cleared when the source recovers and capped at 4096 identities per ecosystem, and a trusted read of a mirrored copy runs no rules and logs nothing. The evidence lives in the decision and the log only, never in registry metadata. Apart from that, each ecosystem's advisory source has an outage monitor: the first evaluation that cannot consult the database (none loaded, an expired push, a lookup fault, or an open breaker) logs at ERROR, a continuing outage logs at ERROR again at most every 15 minutes, and recovery logs at INFO. A failed poll of the advisory store logs at ERROR at the first failure and again at most every 15 minutes, with recovery at INFO. A request never adds an outage line.

Assumptions

  • Valid

    Écluse trusts the private upstream to hold content it admitted before or first-party content, and a private read does not repeat public admission. When the private and public copies disagree, the disagreement proves neither copy correct, and a damaged private copy can stay served. Écluse keeps no persistent conflict state, does not revalidate the public copy on each read, and does not delete on divergence alone.

  • Valid

    Roles adopt a policy change by eventual consistency, with no coordinated pause and no shared policy authority. The operator follows the documented rollout order: when tightening policy, update admission and writer roles before the Dredger, and when relaxing a deny, update the Dredger before writers rely on the new permission. Limiting a deny to admission relaxes it for the Dredger alone, so it follows the relaxation order, and applying a limited deny at revocation again tightens the Dredger, so the operator pins what must stay and previews the deletions first. A binary that does not know the appliesTo key refuses a configuration that carries it, so it cannot delete under a rule the configuration limits to admission. The operations guide also describes the risk of leaving an old Dredger active during a relaxation. Repeated sweeps remove eligible denied copies once roles agree and older writes finish, subject to backend availability and consent, not at once. The owner assesses the loss of the only copy of a version during a rollout as unlikely, without a measured deployment frequency, and accepts it. The acceptance covers rollout disagreement only, not deletion bugs, poisoned advisories, or unauthorised targets.

  • Valid

    A deny limited to admission is the operator's stated choice, rule by rule, to keep serving from the trusted stores the versions that deny refuses at the gate. The operator limits a deny only where builds that already use those versions must continue, and applies it at revocation where the stored copies must go. Every role's boot log and ecluse check-config list each mount's rules by rule type with the phases each applies at, and print each configured appliesTo key under the rule's configured name with the layer that set it. So the choice is visible on each start, and two rules of one type are told apart by the key lines.

Threat 14: SSRF via the worker back-fill fetch (a blind sink)

  • Elements: back-fill artifact fetch (untrusted)
  • Category: Elevation of privilege (STRIDE)
  • Severity: Low
  • Status: Mitigated
  • Flags: None

Description

The mirror worker fetches the approved artifact from an upstream-declared dist.tarball location to replicate it. Like the serve-path public fetch, this is untrusted egress to a target an attacker can influence, and it carries the same SSRF surface: a dist.tarball steered at an internal or cloud-metadata address. The fetch is a blind sink, because the worker publishes the bytes to the mirror and never returns them to a caller.

Mitigations

  • Implemented No redirects on any data-plane request

    The shared parse entry pins redirectCount=0 on every request it returns, and the mirror-write transport pins it again on every request an adapter's codec forms, whether or not the codec routes through credential attachment. No data-plane request, credential-bearing write, or maintenance delete follows a redirect, so no hop can move a credential after the target check, escape the egress allow-list, or downgrade the scheme.

  • Implemented Outbound host allow-list

    Écluse enforces an outbound host and port allow-list where it builds each request URL. A back-fill location passed the same allow-list at serve time, before its job was enqueued. The allow-list bounds which names can be aimed inward. It does not remove connect-time timing.

  • Implemented https-only egress with certificate validation

    Every outbound registry URL passes through mkRegistryUrl, which rejects any scheme other than https, and a configured endpoint that is not https fails the boot. The trusted private origin, the publication target, the mirror target, the store a delete goes to, and the back-fill fetch all meet this rule on the validating-TLS data-plane manager. Certificate validation authenticates the dialled host. A name steered to an internal or rebound address cannot present a CA-trusted certificate for the requested host, so the connection fails at the handshake before any request body leaves. This closes the resolve-to-internal and DNS-rebinding classes without a resolved-IP recheck.

  • Implemented Serve-only mounts carry no write surface

    A mount with no mirrorTarget holds and mints no write credential and enqueues no back-fill job. When no mount declares a mirror target, the process builds no mirror queue and starts no worker. When some mounts mirror and others do not, the process builds the queue and the worker for the mirroring mounts only.

  • Implemented Worker integrity verification

    The worker verifies the fetched bytes against the upstream's dist.integrity before it publishes them, and it never returns them to a caller. The check fails closed when the strongest digest present uses an algorithm it cannot compute, and it never falls back to a weaker digest. An internal or metadata response cannot match the asserted digest, so such a job fails and drops. The check proves that the bytes match the upstream's assertion. It catches tampering in transit and downgrade, not a hostile upstream or a worker compromise, which the admission gate, the role's grant, and queue access control bound.

Assumptions

None recorded.

Threat 15: Private-upstream aggregation admits the public registry, bypassing the gate

  • Elements: Registry C: private upstream (pull-through read endpoint)
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Transferred
  • Flags: None

Description

The recommended topology unions the trusted stores, first-party A and the mirror B, into the pull-through read endpoint C at the registry level, and CodeArtifact upstream relationships are one such mechanism. If that aggregation also holds a direct connection to the public registry, raw public packages reach clients through C as a trusted source. They skip Écluse's gate entirely: the public admission rules, the public integrity floor, and the quarantine. Private metadata meets only the trusted integrity floor, and a private npm artifact hit applies no floor at serve time. The upstream merge that makes the recommended topology work also makes this the natural misconfiguration: a CodeArtifact repository's default npm-store upstream to npmjs has exactly this shape. The misconfiguration shows in Écluse's own signals: a private hit rate near 100%, few public fetches and few mirror jobs, and versions still inside the quarantine window served from the private side.

Mitigations

None recorded.

Assumptions

  • Valid

    The private upstream, the pull-through read endpoint, unions trusted stores only: the first-party store and Écluse's mirror. It never connects to a public registry, so public content enters through Écluse's gate alone. The registry model and the deployment guide document this configuration, and the operator owns it. Écluse trusts the private upstream by design and cannot see how the registry wires its own upstreams, so it cannot detect a violation.

Threat 16: Connect-time reachability/timing oracle for an attacker-controlled allowlisted DNS

  • Elements: public metadata and artifact fetch (anonymous)
  • Category: Information disclosure (STRIDE)
  • Severity: Low
  • Status: Accepted risk
  • Flags: None

Description

An attacker who controls the DNS for an allowlisted host can repoint it at internal addresses. Certificate validation makes the TLS handshake fail, so Écluse sends no request and leaks no data. The success or failure and the timing of the TCP connect and the TLS handshake still form a coarse internal-reachability or port-scan oracle. The oracle covers only allowlisted hosts whose DNS the attacker already controls. The owner accepts this residual.

Mitigations

  • Implemented Outbound host allow-list

    Écluse enforces an outbound host and port allow-list where it builds each request URL. A back-fill location passed the same allow-list at serve time, before its job was enqueued. The allow-list bounds which names can be aimed inward. It does not remove connect-time timing.

  • Implemented https-only egress with certificate validation

    Every outbound registry URL passes through mkRegistryUrl, which rejects any scheme other than https, and a configured endpoint that is not https fails the boot. The trusted private origin, the publication target, the mirror target, the store a delete goes to, and the back-fill fetch all meet this rule on the validating-TLS data-plane manager. Certificate validation authenticates the dialled host. A name steered to an internal or rebound address cannot present a CA-trusted certificate for the requested host, so the connection fails at the handshake before any request body leaves. This closes the resolve-to-internal and DNS-rebinding classes without a resolved-IP recheck.

Assumptions

None recorded.

Threat 17: Pilot container-role privilege escalation

  • Elements: Écluse Pilot (advisory ingestion)
  • Category: Elevation of privilege (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

An attacker who compromises Pilot could use its role identity's container credentials.

Mitigations

  • Implemented Pilot runs as its own role

    Pilot runs in its own process and container with its own identity, apart from the proxy, and its only S3 call is the artifact upload. A Pilot failure, memory exhaustion included, delays advisory updates and does not stop the proxy.

Assumptions

  • Valid

    The operator gives each role its own identity with the least privilege the role needs, and Écluse cannot check these grants. Under codeArtifact the mirror worker's role policy allows reads of the mirror repository and package publication only, so the policy sets the scope of the minted token. Under registry and verdaccio the worker's static token belongs to a dedicated user whose package rights cover the mirror store only. This scoping is defence in depth behind Écluse's endpoint checks. The Dredger's delete grant covers the mirror repository and, where the operator enables cleanup of the private read repository, package-scoped deletion there, with its read and consent permissions granted separately and no permission over the publication target. The Dredger guide documents these permissions. A role-minted credential is preferred over a static secret, and on a store with no access control of its own the Dredger's account is a dedicated user whose package rights cover the mirror store only. Pilot holds s3:PutObject on the advisory prefix, the proxy holds s3:GetObject only, and the advisory bucket is private. The publisher's credential for the publication target is least privilege too.

Threat 18: Proxy compromised via tampered OSV database

  • Elements: Écluse proxy
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

An attacker who can write to the S3 bucket could supply a tampered osv.db and bypass the vulnerability gates. Worse, they could exploit memory-corruption bugs in the underlying C SQLite engine when the proxy runs a query. A Magellan-style exploit or a malicious trigger is the vector.

Mitigations

  • Implemented Hardened advisory database acceptance

    The proxy opens the advisory database with PRAGMA query_only = ON and PRAGMA trusted_schema = OFF, so an attacker-controlled trigger or view does not run. Before the proxy serves from an artifact, acceptance checks the schema epoch stamp, runs PRAGMA quick_check (which also checks stored values against each STRICT table's declared column types), requires real STRICT tables with the required columns, and checks the ecosystem and the EPSS record. Acceptance returns a failing artifact as a rejection value and remembers its ETag.

  • Implemented Last-good advisory database

    The proxy swaps in a new advisory database atomically, so no read sees a partial file. A rejected artifact, a failed fetch, or a failed Pilot run leaves the last-good database serving in a warm process.

Assumptions

  • Valid

    The operator gives each role its own identity with the least privilege the role needs, and Écluse cannot check these grants. Under codeArtifact the mirror worker's role policy allows reads of the mirror repository and package publication only, so the policy sets the scope of the minted token. Under registry and verdaccio the worker's static token belongs to a dedicated user whose package rights cover the mirror store only. This scoping is defence in depth behind Écluse's endpoint checks. The Dredger's delete grant covers the mirror repository and, where the operator enables cleanup of the private read repository, package-scoped deletion there, with its read and consent permissions granted separately and no permission over the publication target. The Dredger guide documents these permissions. A role-minted credential is preferred over a static secret, and on a store with no access control of its own the Dredger's account is a dedicated user whose package rights cover the mirror store only. Pilot holds s3:PutObject on the advisory prefix, the proxy holds s3:GetObject only, and the advisory bucket is private. The publisher's credential for the publication target is least privilege too.

Threat 20: Pathological OSV Payload (DoS)

  • Elements: Écluse Pilot (advisory ingestion)
  • Category: Denial of service (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

OSV.dev, or a compromised upstream, could serve an oversized, deeply nested, or malformed JSON file, whether crafted to attack the parser or unexpectedly large. Parsing it could exhaust CPU or memory and crash Pilot.

Mitigations

  • Implemented Pilot runs as its own role

    Pilot runs in its own process and container with its own identity, apart from the proxy, and its only S3 call is the artifact upload. A Pilot failure, memory exhaustion included, delays advisory updates and does not stop the proxy.

  • Implemented Last-good advisory database

    The proxy swaps in a new advisory database atomically, so no read sees a partial file. A rejected artifact, a failed fetch, or a failed Pilot run leaves the last-good database serving in a warm process.

  • Implemented Bounded advisory ingestion

    Pilot streams the OSV archive and bounds each advisory as it unzips. It drains an entry past the 8 MiB per-advisory cap to its boundary and drops it before decoding. It also drops undecodable JSON and logs and counts both outcomes. An anomalous range fan-out draws a warning but remains eligible for ingestion. This per-entry byte cap does not prove a nesting or decoded-heap bound. Pilot runs under the resolved runtime heap ceiling. A small but deeply nested payload can still crash Pilot. That failure is a process failure, not a per-record drop. Serve metadata admission and npm or PyPI field extraction do not govern advisory ingestion.

  • Implemented Systemic drop rate aborts the compile

    Pilot reads its own drop count for the run. When at least 16 entries drop and the drops are at least a tenth of the run, Pilot aborts the compile without publishing, so the proxy keeps its last-good database. That rate marks a mostly unusable feed, the shape of a compromised or truncated export.

  • Implemented Zero-output refusal

    After ecosystem filtering, Pilot counts the persisted advisory ranges. When none remain, including for an empty or wrong-ecosystem export, it logs ERROR and publishes nothing, before it writes success metadata. A rejected compile keeps the last-good bytes and timestamps, and a first rejection leaves no artifact. A nonempty but reduced export still passes.

Assumptions

  • Valid

    The operator's edge limits request volume and concurrency for proxy callers and Pilot's advisory fetches. Metadata admission limits the concurrency and the live data of serve metadata work, not total process memory or work across all roles. Route selection, including PyPI filename parsing, runs before edge authentication and metadata admission. The edge therefore bounds total routing work and demand from distinct request keys.

Threat 21: Massive Purge DoS

  • Elements: Écluse Dredger
  • Category: Denial of service (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

A bug in Dredger, or a malicious rule configuration, could fire thousands of deletion requests at once. That exhausts the registry API limits and denies service to the private mirror.

Mitigations

  • Implemented Per-cycle deletion cap with a latched halt

    The Dredger counts deletions per cycle, from zero at the start of each cycle. Reaching the cap latches a halt for the life of the process, sets readiness false, and leaves liveness healthy, so the cap does not cause a restart and does not simply wait for the next cycle's fresh count. A restart clears the latch and separate processes count separately, so the cap is not a lifetime or store-wide quota.

  • Implemented Paced candidate-driven sweeps

    A sweep considers the names that advisories cover and the names that identity-deny rules name, not every stored package, unless the operator selects a full walk. Work runs in paced chunks, and a store failure that survives its bounded retry halts the cycle.

Assumptions

None recorded.

Threat 22: Mirror-write credential can be sent to a misconfigured registry target

  • Elements: publish mirrored artifact (mirror worker identity)
  • Category: Information disclosure (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

A mirror-write credential is a live bearer token. Under the minting tag it is minted and scoped to a domain, and under the others it is the operator's static token. If an operator could choose the write credential and the mirror-target endpoint independently, the two could diverge. They could point the mirror target at one registry while the token was minted for another. That would disclose the bearer to an endpoint that could log or replay it.

Mitigations

  • Implemented Endpoint collision refusal at boot

    The boot and check-config compare declared endpoints before any role starts, and each refusal names the keys involved. Registry identity folds the authority to lower case, applies the default port, ignores a trailing slash, and compares the path exactly, so distinct repositories on one host stay distinct, and so do the repositories of one CodeArtifact domain and a repository's per-format endpoints. Every role refuses a private upstream that is the same repository as its own mount's public upstream, a mirror target or publication target on the host of any mount's public upstream, a publication target equal to another mount's private upstream, mirror target, or publication target, and two endpoints that name one registry under different store tags, including a second tag added by an environment layer. The Dredger, preview included, also refuses a mirror target equal to any mount's private upstream or to its own mount's publication target. It refuses a publication target equal to its own mount's private upstream. Its deletions there would reach first-party packages that exist only in the publication target. The proxy and the mirror worker boot on the two mirror-target pairs and warn once for each. They boot on a publication target equal to their own mount's private upstream and say nothing, because neither role deletes. One combinator maps each collision to the outcome of the booting role, so a refusal, a warning, and silence come from one rule. The publish relay takes its target as a VettedPublication, whose constructor the validation module does not export, so every target the relay receives comes from this boot check. The check adds no restriction for DNS aliases.

  • Implemented Store tag binds credential to endpoint

    An endpoint declares its store as a tag, and the load validates the URL against that tag, so the credential and the endpoint are one declaration. Under codeArtifact, Écluse mints the write token from the container or task role, parses the mint identity from the target's own host, and admits no static token. The token carries the role's own permissions, and CodeArtifact caps its life at 12 hours. Under registry and verdaccio, the tag requires the operator's static token and mints nothing. The schema refuses a token under the minting tag and a missing token under the others, so the configuration cannot state a divergent pair. Validation refuses a codeArtifact URL whose host is not a CodeArtifact endpoint, and a mirror target whose path is not the repository endpoint for the mount's own ecosystem, so a token minted for one domain reaches no other. Each refusal names the key.

  • Implemented Serve-only mounts carry no write surface

    A mount with no mirrorTarget holds and mints no write credential and enqueues no back-fill job. When no mount declares a mirror target, the process builds no mirror queue and starts no worker. When some mounts mirror and others do not, the process builds the queue and the worker for the mirroring mounts only.

Assumptions

  • Valid

    The operator gives each role its own identity with the least privilege the role needs, and Écluse cannot check these grants. Under codeArtifact the mirror worker's role policy allows reads of the mirror repository and package publication only, so the policy sets the scope of the minted token. Under registry and verdaccio the worker's static token belongs to a dedicated user whose package rights cover the mirror store only. This scoping is defence in depth behind Écluse's endpoint checks. The Dredger's delete grant covers the mirror repository and, where the operator enables cleanup of the private read repository, package-scoped deletion there, with its read and consent permissions granted separately and no permission over the publication target. The Dredger guide documents these permissions. A role-minted credential is preferred over a static secret, and on a store with no access control of its own the Dredger's account is a dedicated user whose package rights cover the mirror store only. Pilot holds s3:PutObject on the advisory prefix, the proxy holds s3:GetObject only, and the advisory bucket is private. The publisher's credential for the publication target is least privilege too.

Threat 23: Package-name spoofing via invisible characters

  • Elements: install and publish (caller credential)
  • Category: Spoofing (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

A requested or published package name can impersonate another package to a human reader. Unicode format characters (zero-width and bidirectional controls) render invisibly or reorder glyphs, so two distinct names look identical in a lockfile, a log line, or a review diff. A name can arrive from an upstream fetch as well as from a first-party publish, so the boundary applies to both directions.

Mitigations

  • Implemented ASCII package-name allowlist

    Every package-name component parses against an explicit ASCII allowlist before routing, caching, queueing, or publish admission, on the serve path and on the publish path. For npm the allowlist is letters, digits, and - _ . ! ~ * ' ( ), with @ and / as scope structure and no leading period, hyphen, or underscore. The parser refuses a non-ASCII or control codepoint. The allowlist keeps every name visible and comparable, but it does not detect look-alike names inside the permitted set.

Assumptions

None recorded.

Threat 24: Package-name typosquatting within the permitted character set

  • Elements: install and publish (caller credential)
  • Category: Spoofing (STRIDE)
  • Severity: Medium
  • Status: Accepted risk
  • Flags: None

Description

A name built only from permitted ASCII characters can read as another name to a human: a capital I in place of a lowercase l, rn in place of m, a swapped or doubled letter, a hyphen moved or dropped. The upstream npm namespace already contains such look-alike names, and a first-party publish can introduce one. A reader of a lockfile, a log line, or a review diff resolves the wrong package. No safe look-alike detection can be designed now, because any similarity rule also flags legitimate packages. The owner accepts this risk. The ASCII allowlist keeps every name visible and comparable, first-party namespaces keep the deployment's own names off the public leg, and people review the names in lockfiles and dependency diffs.

Mitigations

  • Implemented ASCII package-name allowlist

    Every package-name component parses against an explicit ASCII allowlist before routing, caching, queueing, or publish admission, on the serve path and on the publish path. For npm the allowlist is letters, digits, and - _ . ! ~ * ' ( ), with @ and / as scope structure and no leading period, hyphen, or underscore. The parser refuses a non-ASCII or control codepoint. The allowlist keeps every name visible and comparable, but it does not detect look-alike names inside the permitted set.

Assumptions

  • Valid

    People who add or update a dependency read the package names in lockfiles and dependency diffs before they accept the change. Écluse does not detect names that read as another name inside the permitted ASCII set, so this review is what catches a look-alike name.

Threat 25: Dredger inappropriately purges valid packages

  • Elements: Écluse Dredger
  • Category: Denial of service (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

A misconfiguration in Dredger, or poisoned OSV data, could delete legitimate, needed packages from Registry B. That causes cache misses or upstream fetch failures. A deny that the operator meant to refuse new versions only, and left applying at revocation, is one such misconfiguration: the Dredger deletes the stored copies the deny names.

Mitigations

  • Implemented Deletion only on a named decisive deny

    The Dredger deletes a version only when a named rule denies it decisively. It evaluates the mount's rules that apply at revocation, which the composition root selects in one place, and it never reads a rule limited to admission. The load admits that limit on a deny alone and refuses a rule that applies at revocation alone, so on the same evidence the Dredger never deletes a version the gate admits. Deny-by-default and unvettable results keep the version. Identity-only evidence, a listed version with no readable metadata, goes to that same rule set: any rule that decides from name and version can condemn the version, such as a bare-name DenyByIdentity, DenyIfCve, or DenyIfEpss, and a rule that needs a fact the evidence lacks keeps the version. Rule precedence and every deletion guard still apply. If removing an allow exposes a winning deny that applies at revocation, normal pruning applies, and later cycles reconcile eligible late publications under current evidence.

  • Implemented First-party names are outside deletion

    The Dredger skips names under a first-party declaration before any rule runs, so no deny, including an identity deny, deletes them. This protection also covers public-derived copies retained before the declaration.

  • Implemented Deletion from vetted targets

    The Dredger deletes from a mirror target and a private read cache only after each has passed the boot vetting pass. The composition wires the backend that pass clears into the code that builds the delete capability. A refusal from any vetting rule fails the whole pass, so an overlapping configuration does not reach a delete. Deletion is format-scoped and uses plain version deletion (DeletePackageVersions on CodeArtifact), never disposal.

  • Implemented Per-target deletion consent

    The Dredger checks explicit consent on its target at the start of every sweep cycle. On CodeArtifact, consent is the repository tag ecluse-dredger-consent=true, read through maintenance permissions, and removing the tag takes effect on the next cycle. On Verdaccio, consent is permitDeletion in boot configuration, so a change needs a restart. The boot refuses a backend that has no form of consent. Consent is separate from the credential's capability, so holding the credential is not permission to delete, and pointing the Dredger at a store that carries no consent marker fails the check.

  • Implemented Per-cycle deletion cap with a latched halt

    The Dredger counts deletions per cycle, from zero at the start of each cycle. Reaching the cap latches a halt for the life of the process, sets readiness false, and leaves liveness healthy, so the cap does not cause a restart and does not simply wait for the next cycle's fresh count. A restart clears the latch and separate processes count separately, so the cap is not a lifetime or store-wide quota.

  • Implemented Dry run without delete capability

    A dry run holds no delete capability, writes no cursor, and needs no consent. It reports consent, unmet prerequisites for a real sweep, and incomplete counts separately, and it cannot grant consent or acquire a delete capability.

  • Implemented Maximum advisory age

    Consumers refuse advisory evidence older than the maximum advisory age, measured from Pilot's last push. An explicit advisories.maxAgeSeconds overrides the default, which is the shortest period the quarantine holds a version on that mount, minus 24 hours and never under 3 days, or 3 days when the mount has no quarantine rule. A seven-day hold in the quarantine gives six days, so a failure surfaces before the next cohort leaves the quarantine. Past the maximum age, the CVE and EPSS deny paths refuse even under onUnavailable: skip, and the maximum age applies separately from any rule's skip setting. An undated artifact refuses, and the Dredger withholds advisory-based deletions.

  • Implemented Missing EPSS scores abstain

    When a version's advisory has no EPSS score, the EPSS rule abstains for that advisory. A missing score cannot deny admission or authorise a deletion on its own, and other scored advisories and decisive rules still apply.

Assumptions

  • Valid

    Pilot relies on OSV as the source of vulnerability truth. A compromised OSV is a foundational supply-chain compromise that defeats advisory-based rules outright: it can cause false denials and eligible Dredger deletions, or admit a malicious remediation. Transport, parsing, validation, and last-good-database controls handle tampering in transit, malformed payloads, and outages, but they cannot make a hostile source trustworthy. The owner accepts this residual risk.

Threat 26: Dredger container-role privilege escalation

  • Elements: Écluse Dredger
  • Category: Elevation of privilege (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

Dredger holds a standing privilege to delete from the mirror store (Registry B). An attacker who compromised Dredger could wipe that store. What bounds the privilege depends on the store. A store with its own access control can grant delete without write. A store whose only control plane is the ecosystem protocol offers no such split, so its delete runs over the same token that writes the mirror.

Mitigations

  • Implemented Per-target deletion consent

    The Dredger checks explicit consent on its target at the start of every sweep cycle. On CodeArtifact, consent is the repository tag ecluse-dredger-consent=true, read through maintenance permissions, and removing the tag takes effect on the next cycle. On Verdaccio, consent is permitDeletion in boot configuration, so a change needs a restart. The boot refuses a backend that has no form of consent. Consent is separate from the credential's capability, so holding the credential is not permission to delete, and pointing the Dredger at a store that carries no consent marker fails the check.

  • Implemented Dredger takes no request ingress

    The Dredger takes no ingress beyond its liveness and readiness probes and, when the operator sets OTEL_METRICS_EXPORTER=prometheus, a metrics scrape listener.

Assumptions

  • Valid

    The operator gives each role its own identity with the least privilege the role needs, and Écluse cannot check these grants. Under codeArtifact the mirror worker's role policy allows reads of the mirror repository and package publication only, so the policy sets the scope of the minted token. Under registry and verdaccio the worker's static token belongs to a dedicated user whose package rights cover the mirror store only. This scoping is defence in depth behind Écluse's endpoint checks. The Dredger's delete grant covers the mirror repository and, where the operator enables cleanup of the private read repository, package-scoped deletion there, with its read and consent permissions granted separately and no permission over the publication target. The Dredger guide documents these permissions. A role-minted credential is preferred over a static secret, and on a store with no access control of its own the Dredger's account is a dedicated user whose package rights cover the mirror store only. Pilot holds s3:PutObject on the advisory prefix, the proxy holds s3:GetObject only, and the advisory bucket is private. The publisher's credential for the publication target is least privilege too.

Threat 28: First-party data loss from collapsed registries

  • Elements: Écluse Dredger
  • Category: Denial of service (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

An operator can point a mount's mirrorTarget at a registry that another role already uses, such as any mount's publicationTarget or privateUpstream. Dredger deletes on a decisive deny against a mirrored public version. First-party packages and privately served packages in that same store then fall inside the blast radius.

Mitigations

  • Implemented Endpoint collision refusal at boot

    The boot and check-config compare declared endpoints before any role starts, and each refusal names the keys involved. Registry identity folds the authority to lower case, applies the default port, ignores a trailing slash, and compares the path exactly, so distinct repositories on one host stay distinct, and so do the repositories of one CodeArtifact domain and a repository's per-format endpoints. Every role refuses a private upstream that is the same repository as its own mount's public upstream, a mirror target or publication target on the host of any mount's public upstream, a publication target equal to another mount's private upstream, mirror target, or publication target, and two endpoints that name one registry under different store tags, including a second tag added by an environment layer. The Dredger, preview included, also refuses a mirror target equal to any mount's private upstream or to its own mount's publication target. It refuses a publication target equal to its own mount's private upstream. Its deletions there would reach first-party packages that exist only in the publication target. The proxy and the mirror worker boot on the two mirror-target pairs and warn once for each. They boot on a publication target equal to their own mount's private upstream and say nothing, because neither role deletes. One combinator maps each collision to the outcome of the booting role, so a refusal, a warning, and silence come from one rule. The publish relay takes its target as a VettedPublication, whose constructor the validation module does not export, so every target the relay receives comes from this boot check. The check adds no restriction for DNS aliases.

  • Implemented First-party names are outside deletion

    The Dredger skips names under a first-party declaration before any rule runs, so no deny, including an identity deny, deletes them. This protection also covers public-derived copies retained before the declaration.

  • Implemented Deletion from vetted targets

    The Dredger deletes from a mirror target and a private read cache only after each has passed the boot vetting pass. The composition wires the backend that pass clears into the code that builds the delete capability. A refusal from any vetting rule fails the whole pass, so an overlapping configuration does not reach a delete. Deletion is format-scoped and uses plain version deletion (DeletePackageVersions on CodeArtifact), never disposal.

Assumptions

  • Valid

    The deployment guide recommends three registries, each governed on its own: a first-party store, a public-derived mirror store, and a pull-through read endpoint that unions the other two at the registry level. Écluse supports collapsing these roles onto fewer stores, down to one, and the guide discourages it. An operator who collapses them gives up the separation of first-party and public-derived inventory, per-store policy, and the Dredger's automated pruning, and accepts that residual against their own threat tolerance. The collapse weakens auditability and defence in depth. It does not remove the admission gate.

Threat 29: Publisher credential relayed to a misdirected publication target

  • Elements: relay first-party publish (publisher or static publication token)
  • Category: Information disclosure (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

The publish path sends the caller's credential or a configured static publication token to the operator-declared publication target. A publication target written by mistake, or edited to a wrong value, would send that live bearer credential to an unintended registry. A public registry is the worst case: an operator who points the publication target at the public upstream turns every publish into a credential disclosure to a third party that can log or replay it.

Mitigations

  • Implemented Static publication token behind the edge token

    A static publication-target token (mounts.<eco>.publicationTarget.<tag>.token) requires server.authToken, and the boot refuses a configuration with one and not the other. After the relay authenticates the edge token, it replaces that token with the static token, so the edge secret does not reach the publication target. Without a static token, a closed-edge publish forwards the edge token unchanged, and an open-edge publish forwards the caller's own credential.

  • Implemented Per-caller credential passthrough

    The read path never substitutes a credential of Écluse's own. It forwards the caller's credential to the private upstream and strips caller credentials from public fetches, which it makes anonymously. The publish path sends credentials only to the operator-declared publication target, and Écluse mints no publication token.

  • Implemented No redirects on any data-plane request

    The shared parse entry pins redirectCount=0 on every request it returns, and the mirror-write transport pins it again on every request an adapter's codec forms, whether or not the codec routes through credential attachment. No data-plane request, credential-bearing write, or maintenance delete follows a redirect, so no hop can move a credential after the target check, escape the egress allow-list, or downgrade the scheme.

  • Implemented Endpoint collision refusal at boot

    The boot and check-config compare declared endpoints before any role starts, and each refusal names the keys involved. Registry identity folds the authority to lower case, applies the default port, ignores a trailing slash, and compares the path exactly, so distinct repositories on one host stay distinct, and so do the repositories of one CodeArtifact domain and a repository's per-format endpoints. Every role refuses a private upstream that is the same repository as its own mount's public upstream, a mirror target or publication target on the host of any mount's public upstream, a publication target equal to another mount's private upstream, mirror target, or publication target, and two endpoints that name one registry under different store tags, including a second tag added by an environment layer. The Dredger, preview included, also refuses a mirror target equal to any mount's private upstream or to its own mount's publication target. It refuses a publication target equal to its own mount's private upstream. Its deletions there would reach first-party packages that exist only in the publication target. The proxy and the mirror worker boot on the two mirror-target pairs and warn once for each. They boot on a publication target equal to their own mount's private upstream and say nothing, because neither role deletes. One combinator maps each collision to the outcome of the booting role, so a refusal, a warning, and silence come from one rule. The publish relay takes its target as a VettedPublication, whose constructor the validation module does not export, so every target the relay receives comes from this boot check. The check adds no restriction for DNS aliases.

  • Implemented https-only egress with certificate validation

    Every outbound registry URL passes through mkRegistryUrl, which rejects any scheme other than https, and a configured endpoint that is not https fails the boot. The trusted private origin, the publication target, the mirror target, the store a delete goes to, and the back-fill fetch all meet this rule on the validating-TLS data-plane manager. Certificate validation authenticates the dialled host. A name steered to an internal or rebound address cannot present a CA-trusted certificate for the requested host, so the connection fails at the handshake before any request body leaves. This closes the resolve-to-internal and DNS-rebinding classes without a resolved-IP recheck.

Assumptions

None recorded.

Threat 101: Oracle Blackout / Supply Chain DoS via OSV.dev compromise

  • Elements: Écluse Pilot (advisory ingestion)
  • Category: Spoofing (STRIDE)
  • Severity: High
  • Status: Accepted risk
  • Flags: None

Description

An attacker who gains control of osv.dev can push malicious vulnerability records. Those records trigger false positives, or fast-lane a malicious remediation package. The attack is strongest when the attacker also publishes a malicious package. A hostile oracle defeats advisory-based rules outright.

Mitigations

None recorded.

Assumptions

  • Valid

    Pilot relies on OSV as the source of vulnerability truth. A compromised OSV is a foundational supply-chain compromise that defeats advisory-based rules outright: it can cause false denials and eligible Dredger deletions, or admit a malicious remediation. Transport, parsing, validation, and last-good-database controls handle tampering in transit, malformed payloads, and outages, but they cannot make a hostile source trustworthy. The owner accepts this residual risk.

Threat 102: Accidental permanent deletion of registry data

  • Elements: Écluse Dredger
  • Category: Elevation of privilege (STRIDE)
  • Severity: Critical
  • Status: Mitigated
  • Flags: None

Description

Dredger issues permanent hard deletions against the mirror registry. Misconfigured, or pointed at the wrong registry, it destroys data permanently. Plain deletion permits a later publication of the same version, but recovery needs a remaining source of the bytes.

Mitigations

  • Implemented Endpoint collision refusal at boot

    The boot and check-config compare declared endpoints before any role starts, and each refusal names the keys involved. Registry identity folds the authority to lower case, applies the default port, ignores a trailing slash, and compares the path exactly, so distinct repositories on one host stay distinct, and so do the repositories of one CodeArtifact domain and a repository's per-format endpoints. Every role refuses a private upstream that is the same repository as its own mount's public upstream, a mirror target or publication target on the host of any mount's public upstream, a publication target equal to another mount's private upstream, mirror target, or publication target, and two endpoints that name one registry under different store tags, including a second tag added by an environment layer. The Dredger, preview included, also refuses a mirror target equal to any mount's private upstream or to its own mount's publication target. It refuses a publication target equal to its own mount's private upstream. Its deletions there would reach first-party packages that exist only in the publication target. The proxy and the mirror worker boot on the two mirror-target pairs and warn once for each. They boot on a publication target equal to their own mount's private upstream and say nothing, because neither role deletes. One combinator maps each collision to the outcome of the booting role, so a refusal, a warning, and silence come from one rule. The publish relay takes its target as a VettedPublication, whose constructor the validation module does not export, so every target the relay receives comes from this boot check. The check adds no restriction for DNS aliases.

  • Implemented Deletion only on a named decisive deny

    The Dredger deletes a version only when a named rule denies it decisively. It evaluates the mount's rules that apply at revocation, which the composition root selects in one place, and it never reads a rule limited to admission. The load admits that limit on a deny alone and refuses a rule that applies at revocation alone, so on the same evidence the Dredger never deletes a version the gate admits. Deny-by-default and unvettable results keep the version. Identity-only evidence, a listed version with no readable metadata, goes to that same rule set: any rule that decides from name and version can condemn the version, such as a bare-name DenyByIdentity, DenyIfCve, or DenyIfEpss, and a rule that needs a fact the evidence lacks keeps the version. Rule precedence and every deletion guard still apply. If removing an allow exposes a winning deny that applies at revocation, normal pruning applies, and later cycles reconcile eligible late publications under current evidence.

  • Implemented Deletion from vetted targets

    The Dredger deletes from a mirror target and a private read cache only after each has passed the boot vetting pass. The composition wires the backend that pass clears into the code that builds the delete capability. A refusal from any vetting rule fails the whole pass, so an overlapping configuration does not reach a delete. Deletion is format-scoped and uses plain version deletion (DeletePackageVersions on CodeArtifact), never disposal.

  • Implemented Per-target deletion consent

    The Dredger checks explicit consent on its target at the start of every sweep cycle. On CodeArtifact, consent is the repository tag ecluse-dredger-consent=true, read through maintenance permissions, and removing the tag takes effect on the next cycle. On Verdaccio, consent is permitDeletion in boot configuration, so a change needs a restart. The boot refuses a backend that has no form of consent. Consent is separate from the credential's capability, so holding the credential is not permission to delete, and pointing the Dredger at a store that carries no consent marker fails the check.

  • Implemented Per-cycle deletion cap with a latched halt

    The Dredger counts deletions per cycle, from zero at the start of each cycle. Reaching the cap latches a halt for the life of the process, sets readiness false, and leaves liveness healthy, so the cap does not cause a restart and does not simply wait for the next cycle's fresh count. A restart clears the latch and separate processes count separately, so the cap is not a lifetime or store-wide quota.

  • Implemented Dry run without delete capability

    A dry run holds no delete capability, writes no cursor, and needs no consent. It reports consent, unmet prerequisites for a real sweep, and incomplete counts separately, and it cannot grant consent or acquire a delete capability.

Assumptions

None recorded.

Threat 103: Dependency confusion through a first-party namespace on the public upstream

  • Elements: public metadata and artifact fetch (anonymous)
  • Category: Spoofing (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

A mount declares the namespaces the deployment owns. An attacker registers a package on the public upstream under a name, scope, or prefix inside one of them, choosing a name the private store does not carry, or a version of one it does. Without the privilege the public leg would answer for that name: the packument merge would fold the public document in, the artifact path would stream its bytes after a private miss, and the mirror would write the admitted version into the deployment's own store. A build resolving a first-party dependency would install the attacker's package. That is dependency confusion, and the public leg is where it enters. Each role reads the declaration from its own configuration, so during a rollout an older worker can still apply the earlier predicate. A namespace that is not declared receives no first-party protection.

Mitigations

  • Implemented Worker re-evaluation before mirroring

    Public admission applies the configured rules before a version is queued, and the worker applies them again, with its current first-party predicate, before it downloads the artifact. A winning named deny stops new admission when its evidence is available, and the worker drops a queued job for a first-party name.

  • Implemented First-party names resolve privately only

    A declared first-party name resolves only through the private upstream. Metadata does not fetch or merge the public origin, and an artifact read does not fall back to the public origin after a private miss. npm scopes match exactly, and PyPI declarations name canonical project names or prefixes bounded by a separator. The load refuses an empty declaration.

  • Implemented First-party names are outside deletion

    The Dredger skips names under a first-party declaration before any rule runs, so no deny, including an identity deny, deletes them. This protection also covers public-derived copies retained before the declaration.

Assumptions

  • Valid

    Before an operator declares a namespace first-party, they review its existing copies in the mirror and in the private read repository, remove the public-derived versions they do not want, apply the declaration to every role, and check both stores again after older writes finish. Écluse does not hold back, clean up, or override the first-party guard for a newly declared namespace, so copies retained before the declaration stay served and protected from the Dredger.

  • Elements: Écluse Dredger
  • Category: Elevation of privilege (STRIDE)
  • Severity: Medium
  • Status: Accepted risk
  • Flags: None

Description

The verdaccio tag carries deletion consent as permitDeletion: true, a key in the mount's own configuration, so consent travels with the deployment rather than with the store. An operator who re-points the verdaccio: mirror target at a different Verdaccio store keeps a configuration that still says yes, and the Dredger reads consent for a store nobody granted it for. The same key carries the store's classification, so a re-pointed target is also read as standalone. The endpoint collision refusal does not catch a re-pointed target that collides with no other declared endpoint, so the Dredger deletes from it.

Mitigations

  • Implemented Endpoint collision refusal at boot

    The boot and check-config compare declared endpoints before any role starts, and each refusal names the keys involved. Registry identity folds the authority to lower case, applies the default port, ignores a trailing slash, and compares the path exactly, so distinct repositories on one host stay distinct, and so do the repositories of one CodeArtifact domain and a repository's per-format endpoints. Every role refuses a private upstream that is the same repository as its own mount's public upstream, a mirror target or publication target on the host of any mount's public upstream, a publication target equal to another mount's private upstream, mirror target, or publication target, and two endpoints that name one registry under different store tags, including a second tag added by an environment layer. The Dredger, preview included, also refuses a mirror target equal to any mount's private upstream or to its own mount's publication target. It refuses a publication target equal to its own mount's private upstream. Its deletions there would reach first-party packages that exist only in the publication target. The proxy and the mirror worker boot on the two mirror-target pairs and warn once for each. They boot on a publication target equal to their own mount's private upstream and say nothing, because neither role deletes. One combinator maps each collision to the outcome of the booting role, so a refusal, a warning, and silence come from one rule. The publish relay takes its target as a VettedPublication, whose constructor the validation module does not export, so every target the relay receives comes from this boot check. The check adds no restriction for DNS aliases.

  • Implemented Per-target deletion consent

    The Dredger checks explicit consent on its target at the start of every sweep cycle. On CodeArtifact, consent is the repository tag ecluse-dredger-consent=true, read through maintenance permissions, and removing the tag takes effect on the next cycle. On Verdaccio, consent is permitDeletion in boot configuration, so a change needs a restart. The boot refuses a backend that has no form of consent. Consent is separate from the credential's capability, so holding the credential is not permission to delete, and pointing the Dredger at a store that carries no consent marker fails the check.

Assumptions

  • Valid

    Verdaccio is a development and test store. Nobody runs a package proxy in front of a package proxy in production, so the exception for the verdaccio tag exists on no other tag. Verdaccio serves no marker a protocol read could carry, and no protocol read shows whether the store refills from an uplink, so the operator's permitDeletion key is the one evidence of consent and of the store's classification. The owner accepts that a re-pointed verdaccio target keeps its consent.

Threat 105: Store-supplied path components steer a credential-bearing delete

  • Elements: delete denied versions
  • Category: Elevation of privilege (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

Removing one version from a protocol-only store is an edit over the store's own package document. Écluse reads that document, takes the revision marker and the artifact's file name out of it, puts the revision into the URLs of a PUT and a DELETE, and the file name into the DELETE, both carrying the mirror-write token. Écluse authored neither value, and a publisher supplies the tarball location the file name is taken from. A component holding a separator, a dot-dot, or a control character could steer either request at another path on the store.

Mitigations

  • Implemented Fail-closed document limits

    Fail-closed limits still bound metadata body bytes, version count, artifact count and retained structure depth (ECLUSE_LIMITS__MAX_RESPONSE_BYTES, ECLUSE_LIMITS__MAX_VERSION_COUNT, ECLUSE_LIMITS__MAX_ARTIFACT_COUNT, ECLUSE_LIMITS__MAX_NESTING_DEPTH). The reader counts decompressed bytes and stops an oversized response. Dredger reads store package documents under the same body bound. npm and PyPI extract supported fields by walking the tokens of the maintained json-stream lexer, checking structural counts during extraction instead of projecting a complete source document first. Skipped fields do not become decoded values. A release or file the projection drops, and each repeat of a member within an object, never enters the read's table of shared strings, and a repeated member is dropped as it arrives. Extraction does not establish whole-document JSON validity. Supported fields still materialise, and listings retain useful release and artifact results. Body limits and structural limits remain separate from memory admission. Neither streaming nor the response cap proves a bound on the resulting heap. Advisory-backed rules still perform work for each evaluated version.

  • Implemented No redirects on any data-plane request

    The shared parse entry pins redirectCount=0 on every request it returns, and the mirror-write transport pins it again on every request an adapter's codec forms, whether or not the codec routes through credential attachment. No data-plane request, credential-bearing write, or maintenance delete follows a redirect, so no hop can move a credential after the target check, escape the egress allow-list, or downgrade the scheme.

  • Implemented https-only egress with certificate validation

    Every outbound registry URL passes through mkRegistryUrl, which rejects any scheme other than https, and a configured endpoint that is not https fails the boot. The trusted private origin, the publication target, the mirror target, the store a delete goes to, and the back-fill fetch all meet this rule on the validating-TLS data-plane manager. Certificate validation authenticates the dialled host. A name steered to an internal or rebound address cannot present a CA-trusted certificate for the requested host, so the connection fails at the handshake before any request body leaves. This closes the resolve-to-internal and DNS-rebinding classes without a resolved-IP recheck.

  • Implemented Gated path components on store deletes

    Before a delete on a protocol-only store, the Dredger passes the revision marker and the artifact file name from the store's package document through the component gate the serve path uses. The gate refuses an empty component, . or .., and any component that holds a slash, a backslash, or a control character. A file name that fails falls back to the conventional name built from the package and the version, and a revision marker that fails refuses that one version. The build then percent-encodes every component and keeps only the unreserved set verbatim, so safety rests on encoding as well as on the gate. The gate is structural, so a valid component that is not the version's own artifact still forms a delete, bounded to the one mirror store the credential reaches.

Assumptions

None recorded.

Threat 106: Cold-start advisory availability depends on S3

  • Elements: Advisory store (S3)
  • Category: Denial of service (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

A proxy configured to sync advisories from S3 starts without a loaded advisory generation. An S3 outage, a missing object, or a denied read can prevent its first sync. A replaced Kubernetes Pod loses its emptyDir data. A container restart within the same Pod can retain that data, but the proxy does not load a retained database at boot. Readiness alone does not block direct requests.

Mitigations

  • Implemented Maximum advisory age

    Consumers refuse advisory evidence older than the maximum advisory age, measured from Pilot's last push. An explicit advisories.maxAgeSeconds overrides the default, which is the shortest period the quarantine holds a version on that mount, minus 24 hours and never under 3 days, or 3 days when the mount has no quarantine rule. A seven-day hold in the quarantine gives six days, so a failure surfaces before the next cohort leaves the quarantine. Past the maximum age, the CVE and EPSS deny paths refuse even under onUnavailable: skip, and the maximum age applies separately from any rule's skip setting. An undated artifact refuses, and the Dredger withholds advisory-based deletions.

  • Implemented Last-good advisory database

    The proxy swaps in a new advisory database atomically, so no read sees a partial file. A rejected artifact, a failed fetch, or a failed Pilot run leaves the last-good database serving in a warm process.

  • Implemented Advisory startup retries without exit

    When the proxy cannot acquire an advisory artifact at start, the process stays alive, retries within a bounded boot budget, logs ERROR when the budget runs out, and keeps polling. Readiness is false while no mount is ready and true once any mount is ready, so healthy mounts stay routable while another ecosystem's artifact is missing.

  • Implemented Absent advisories fail closed on deny rules

    Requests keep their configured trusted-read, allow, skip, and refusal behaviour when advisories are absent. When evaluation reaches an advisory rule that fails closed, an absent database stops public admission.

  • Implemented Outage reporting and skipped-check evidence

    Each admission carries the configured checks it did not benefit from: a check that ran and could not vet under onUnavailable: skip, kept apart from a check a higher-precedence allow pre-empted, so no consumer reads an unreached check as passed. The public artifact gate logs a skipped check with the package, version, rule, and cause, once per package, version, and skipped rule set for the life of the advisory outage, cleared when the source recovers and capped at 4096 identities per ecosystem, and a trusted read of a mirrored copy runs no rules and logs nothing. The evidence lives in the decision and the log only, never in registry metadata. Apart from that, each ecosystem's advisory source has an outage monitor: the first evaluation that cannot consult the database (none loaded, an expired push, a lookup fault, or an open breaker) logs at ERROR, a continuing outage logs at ERROR again at most every 15 minutes, and recovery logs at INFO. A failed poll of the advisory store logs at ERROR at the first failure and again at most every 15 minutes, with recovery at INFO. A request never adds an outage line.

Assumptions

  • Valid

    A proxy may depend on S3 to load advisories at cold start, without durable local advisory storage or recovery from a retained file. The owner accepts this for availability only. It does not accept stale advisory evidence: past the maximum advisory age, the CVE-blocking path refuses even under onUnavailable: skip.

Threat 107: Downstream cleanup gains authority over unrelated private versions

  • Elements: delete retained copies from the private read cache
  • Category: Elevation of privilege (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

Registry C can retain both mirror-derived and first-party versions. Granting Dredger deletion permission there extends its authority beyond Registry B. A misdirected cleanup or compromised process can remove a private version outside the intended mirrored set. A package-level IAM grant covers that package's versions and does not prove their provenance. CodeArtifact's INTERNAL origin does not prove that a version came from the mirror, because its domain entry point records the original publication, not the last upstream hop. Application checks cannot limit a compromised process beyond its IAM grant. Plain deletion permits later publication, but cannot recover bytes when no source remains.

Mitigations

  • Implemented Authority limits for cleanup of the private read repository

    Cleanup in the private read repository (C) binds each delete to an explicit account, region, domain, repository, ecosystem, package, and version, through its own authorised capability. Each target reads its own consent, and one target's consent does not carry to the other. Cleanup asks for no per-copy proof that a version came from the mirror. The configured private upstream is a read cache by design. A copy whose origin is missing, unrelated, or unknown stays eligible. Boot validation carries that weight instead of a per-package origin check. The Dredger and its preview both refuse a private upstream that is also their own mount's publication target. They both refuse one whose store this build has no inventory control plane for. Only the deleting role refuses a private upstream that withholds deletion consent or supplies no maintenance token. A preview reads the store and changes nothing. C classifies as deletable although it declares upstreams, because a read cache must have them. Its own consent marker is what gates it. First-party names stay outside deletion. The Dredger deletes only a version a named rule decisively denies. The deletion cap and dry run apply. Cleanup uses DeletePackageVersions on B and C, never DisposePackageVersions, DeletePackage, or repository deletion. Cleanup in C does not relax the mirror collision refusals.

  • Implemented Cap counts logical versions

    The per-cycle deletion cap charges one logical version once across its copies in B and C, so operators need not estimate how many copies exist. Per-target operations, retries, and partial results stay separately visible, so one charged version is not mistaken for complete cleanup.

Assumptions

  • Valid

    The operator gives each role its own identity with the least privilege the role needs, and Écluse cannot check these grants. Under codeArtifact the mirror worker's role policy allows reads of the mirror repository and package publication only, so the policy sets the scope of the minted token. Under registry and verdaccio the worker's static token belongs to a dedicated user whose package rights cover the mirror store only. This scoping is defence in depth behind Écluse's endpoint checks. The Dredger's delete grant covers the mirror repository and, where the operator enables cleanup of the private read repository, package-scoped deletion there, with its read and consent permissions granted separately and no permission over the publication target. The Dredger guide documents these permissions. A role-minted credential is preferred over a static secret, and on a store with no access control of its own the Dredger's account is a dedicated user whose package rights cover the mirror store only. Pilot holds s3:PutObject on the advisory prefix, the proxy holds s3:GetObject only, and the advisory bucket is private. The publisher's credential for the publication target is least privilege too.

Threat 108: Partial downstream cleanup leaves revoked content available

  • Elements: delete retained copies from the private read cache
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

Cleanup can remove a version from Registry B and fail before removing Registry C's retained copy. A restart can lose the remaining work, and later B listings no longer name the version. A successful private read then continues serving it. CodeArtifact listings are local to one repository, so neither listing alone shows both states. An available upstream or pending writer can also recreate content after a downstream delete. A pre-read or one successful delete cannot establish final removal across the configured targets.

Mitigations

  • Implemented Reconcile retained copies across the mirror and the private read repository

    The Dredger derives cleanup candidates from the actual inventories of the mirror (B) and the private read repository (C), and it repeats both scans after a failure or a restart. It keeps each location's presence on its own. A copy left only in C stays discoverable after B removal, and a version only in B stays discoverable before C retains it. Before every attempt it reads both inventories again, rechecks the target's own consent, and evaluates each candidate against current policy. A candidate stands only while the target's own entry for it reads the same across those reads. A cache candidate also needs the mirror's entry to read the same. It deletes a copy from C only once B no longer serves a denied copy of that version. It records each target's outcome and retries transient failures with bounded backoff. Scheduling or audit records do not replace repository state and do not authorise a stale decision. The Dredger deletes only a version a named rule decisively denies. It skips first-party names. The Dredger checks the resulting private-read state and reports incomplete cleanup. Once roles agree and older writes finish, repeated scans reconcile late copies.

  • Implemented Cap counts logical versions

    The per-cycle deletion cap charges one logical version once across its copies in B and C, so operators need not estimate how many copies exist. Per-target operations, retries, and partial results stay separately visible, so one charged version is not mistaken for complete cleanup.

Assumptions

  • Valid

    Roles adopt a policy change by eventual consistency, with no coordinated pause and no shared policy authority. The operator follows the documented rollout order: when tightening policy, update admission and writer roles before the Dredger, and when relaxing a deny, update the Dredger before writers rely on the new permission. Limiting a deny to admission relaxes it for the Dredger alone, so it follows the relaxation order, and applying a limited deny at revocation again tightens the Dredger, so the operator pins what must stay and previews the deletions first. A binary that does not know the appliesTo key refuses a configuration that carries it, so it cannot delete under a rule the configuration limits to admission. The operations guide also describes the risk of leaving an old Dredger active during a relaxation. Repeated sweeps remove eligible denied copies once roles agree and older writes finish, subject to backend availability and consent, not at once. The owner assesses the loss of the only copy of a version during a rollout as unlikely, without a measured deployment frequency, and accepts it. The acceptance covers rollout disagreement only, not deletion bugs, poisoned advisories, or unauthorised targets.

  • Unconfirmed

    A mirror transfer takes seconds, while new advisory evidence can take hours to reach the proxy. This is the owner's operational expectation, not a measured bound. Admission before advisory evidence exists is a known limit of the model. Écluse adds no last-moment rule check, cancellation barrier, or distributed write coordination for the window between the worker's check and its publish, and later Dredger sweeps reconcile late copies. The owner accepts this window. The acceptance does not promise immediate revocation and does not waive integrity verification, rule precedence, consent, or first-party protection.

Threat 109: An outdated Dredger policy deletes the only remaining package copy

  • Elements: Écluse Dredger
  • Category: Denial of service (STRIDE)
  • Severity: Medium
  • Status: Accepted risk
  • Flags: None

Description

Loss requires a retained version, a policy disagreement that lets an old Dredger deny what the intended policy permits, a deletion during that disagreement, and no remaining usable source of the bytes. A deny that the intended policy limits to admission, and that an old Dredger still applies at revocation, is one such disagreement. The public source can disappear before the rollout, and no new admission during the rollout is required. The residual covers deletions from the mirror (B) and from the private read repository (C). Later policy agreement cannot restore the lost bytes, and repeated sweeps cannot converge while roles keep applying conflicting policies.

Mitigations

  • Implemented Deletion only on a named decisive deny

    The Dredger deletes a version only when a named rule denies it decisively. It evaluates the mount's rules that apply at revocation, which the composition root selects in one place, and it never reads a rule limited to admission. The load admits that limit on a deny alone and refuses a rule that applies at revocation alone, so on the same evidence the Dredger never deletes a version the gate admits. Deny-by-default and unvettable results keep the version. Identity-only evidence, a listed version with no readable metadata, goes to that same rule set: any rule that decides from name and version can condemn the version, such as a bare-name DenyByIdentity, DenyIfCve, or DenyIfEpss, and a rule that needs a fact the evidence lacks keeps the version. Rule precedence and every deletion guard still apply. If removing an allow exposes a winning deny that applies at revocation, normal pruning applies, and later cycles reconcile eligible late publications under current evidence.

  • Implemented First-party names are outside deletion

    The Dredger skips names under a first-party declaration before any rule runs, so no deny, including an identity deny, deletes them. This protection also covers public-derived copies retained before the declaration.

  • Implemented Deletion from vetted targets

    The Dredger deletes from a mirror target and a private read cache only after each has passed the boot vetting pass. The composition wires the backend that pass clears into the code that builds the delete capability. A refusal from any vetting rule fails the whole pass, so an overlapping configuration does not reach a delete. Deletion is format-scoped and uses plain version deletion (DeletePackageVersions on CodeArtifact), never disposal.

  • Implemented Per-target deletion consent

    The Dredger checks explicit consent on its target at the start of every sweep cycle. On CodeArtifact, consent is the repository tag ecluse-dredger-consent=true, read through maintenance permissions, and removing the tag takes effect on the next cycle. On Verdaccio, consent is permitDeletion in boot configuration, so a change needs a restart. The boot refuses a backend that has no form of consent. Consent is separate from the credential's capability, so holding the credential is not permission to delete, and pointing the Dredger at a store that carries no consent marker fails the check.

  • Implemented Per-cycle deletion cap with a latched halt

    The Dredger counts deletions per cycle, from zero at the start of each cycle. Reaching the cap latches a halt for the life of the process, sets readiness false, and leaves liveness healthy, so the cap does not cause a restart and does not simply wait for the next cycle's fresh count. A restart clears the latch and separate processes count separately, so the cap is not a lifetime or store-wide quota.

Assumptions

  • Valid

    Roles adopt a policy change by eventual consistency, with no coordinated pause and no shared policy authority. The operator follows the documented rollout order: when tightening policy, update admission and writer roles before the Dredger, and when relaxing a deny, update the Dredger before writers rely on the new permission. Limiting a deny to admission relaxes it for the Dredger alone, so it follows the relaxation order, and applying a limited deny at revocation again tightens the Dredger, so the operator pins what must stay and previews the deletions first. A binary that does not know the appliesTo key refuses a configuration that carries it, so it cannot delete under a rule the configuration limits to admission. The operations guide also describes the risk of leaving an old Dredger active during a relaxation. Repeated sweeps remove eligible denied copies once roles agree and older writes finish, subject to backend availability and consent, not at once. The owner assesses the loss of the only copy of a version during a rollout as unlikely, without a measured deployment frequency, and accepts it. The acceptance covers rollout disagreement only, not deletion bugs, poisoned advisories, or unauthorised targets.

Threat 110: An admitted mirror transfer can finish after an advisory changes

  • Elements: publish mirrored artifact (mirror worker identity)
  • Category: Tampering (STRIDE)
  • Severity: Low
  • Status: Accepted risk
  • Flags: None

Description

The worker re-evaluates a queued job before it downloads the artifact. An advisory can change after that check. The pending transfer can then publish a version the new evidence denies, including after Dredger removes an earlier copy, if the backend permits recreation. A later private read trusts the resulting copy until eligible cleanup succeeds.

Mitigations

  • Implemented Worker integrity verification

    The worker verifies the fetched bytes against the upstream's dist.integrity before it publishes them, and it never returns them to a caller. The check fails closed when the strongest digest present uses an algorithm it cannot compute, and it never falls back to a weaker digest. An internal or metadata response cannot match the asserted digest, so such a job fails and drops. The check proves that the bytes match the upstream's assertion. It catches tampering in transit and downgrade, not a hostile upstream or a worker compromise, which the admission gate, the role's grant, and queue access control bound.

  • Implemented Worker re-evaluation before mirroring

    Public admission applies the configured rules before a version is queued, and the worker applies them again, with its current first-party predicate, before it downloads the artifact. A winning named deny stops new admission when its evidence is available, and the worker drops a queued job for a first-party name.

  • Implemented Reconcile retained copies across the mirror and the private read repository

    The Dredger derives cleanup candidates from the actual inventories of the mirror (B) and the private read repository (C), and it repeats both scans after a failure or a restart. It keeps each location's presence on its own. A copy left only in C stays discoverable after B removal, and a version only in B stays discoverable before C retains it. Before every attempt it reads both inventories again, rechecks the target's own consent, and evaluates each candidate against current policy. A candidate stands only while the target's own entry for it reads the same across those reads. A cache candidate also needs the mirror's entry to read the same. It deletes a copy from C only once B no longer serves a denied copy of that version. It records each target's outcome and retries transient failures with bounded backoff. Scheduling or audit records do not replace repository state and do not authorise a stale decision. The Dredger deletes only a version a named rule decisively denies. It skips first-party names. The Dredger checks the resulting private-read state and reports incomplete cleanup. Once roles agree and older writes finish, repeated scans reconcile late copies.

Assumptions

  • Unconfirmed

    A mirror transfer takes seconds, while new advisory evidence can take hours to reach the proxy. This is the owner's operational expectation, not a measured bound. Admission before advisory evidence exists is a known limit of the model. Écluse adds no last-moment rule check, cancellation barrier, or distributed write coordination for the window between the worker's check and its publish, and later Dredger sweeps reconcile late copies. The owner accepts this window. The acceptance does not promise immediate revocation and does not waive integrity verification, rule precedence, consent, or first-party protection.

Threat 111: Incorrect EPSS source scores become authoritative policy evidence

  • Elements: Écluse Pilot (advisory ingestion)
  • Category: Spoofing (STRIDE)
  • Severity: High
  • Status: Open
  • Flags: None

Description

A compromised or incorrect EPSS source can supply plausible scores that pass validation. An overstated score can cause an EPSS denial and an eligible Dredger deletion. An understated score can fail to trigger an EPSS denial. Other configured rules still apply. Loss of EPSS coverage can remove a denial and let another allow admit the version, which does not show that the version is harmless. Transport and schema validity do not establish the accuracy of a predictive score.

Mitigations

  • Implemented Maximum advisory age

    Consumers refuse advisory evidence older than the maximum advisory age, measured from Pilot's last push. An explicit advisories.maxAgeSeconds overrides the default, which is the shortest period the quarantine holds a version on that mount, minus 24 hours and never under 3 days, or 3 days when the mount has no quarantine rule. A seven-day hold in the quarantine gives six days, so a failure surfaces before the next cohort leaves the quarantine. Past the maximum age, the CVE and EPSS deny paths refuse even under onUnavailable: skip, and the maximum age applies separately from any rule's skip setting. An undated artifact refuses, and the Dredger withholds advisory-based deletions.

  • Implemented Zero-output refusal

    After ecosystem filtering, Pilot counts the persisted advisory ranges. When none remain, including for an empty or wrong-ecosystem export, it logs ERROR and publishes nothing, before it writes success metadata. A rejected compile keeps the last-good bytes and timestamps, and a first rejection leaves no artifact. A nonempty but reduced export still passes.

  • Implemented EPSS success record

    Pilot records a successful EPSS enrichment in the artifact metadata, and a consumer with an EPSS-dependent rule refuses an artifact without that record.

  • Implemented Missing EPSS scores abstain

    When a version's advisory has no EPSS score, the EPSS rule abstains for that advisory. A missing score cannot deny admission or authorise a deletion on its own, and other scored advisories and decisive rules still apply.

  • Proposed Advisory size guard

    Planned in #1234. Pilot will refuse a compile whose output shrinks past a configurable guard, set per ecosystem and schema epoch, with a durable high-water mark where the store supports one. The starting defaults are a 2% ordinary shrink plus 12 bytes per prior scored range row, capped at an extra 5% on optional EPSS-loss transitions. An approved adjustment rebases the mark explicitly. Size is a heuristic, not proof of complete coverage.

  • Implemented EPSS failure blocks only EPSS-dependent ecosystems

    Pilot attempts the EPSS feed on every compile, and each ecosystem's resolved policy decides what a failed feed means. An ecosystem with an active DenyIfEpss rule fails the compile and publishes nothing, so its consumers keep their last accepted artifact. Any other ecosystem publishes its OSV data marked epss_status=unavailable, with no scores and no EPSS provenance, so new advisories and their fixes still reach its consumers. A consumer with an EPSS-dependent rule refuses that artifact. A one-shot compile of an ecosystem the configuration does not mount requires the feed. The decision reads Pilot's own configuration, so Pilot and the consumers must share one rule configuration.

Assumptions

  • Valid

    Pilot relies on the configured EPSS feed for the truth of its scores, separately from OSV vulnerability records, and adds no independent check of score accuracy. HTTPS, bounded fetching, parsing, and score validation catch transport faults and malformed input, not plausible wrong scores. The owner accepts this residual risk. The acceptance does not cover completeness, availability, or freshness, which other controls handle.

Threat 112: Stale or incomplete advisory snapshots remain usable policy evidence

  • Elements: Advisory store (S3)
  • Category: Tampering (STRIDE)
  • Severity: Medium
  • Status: Open
  • Flags: None

Description

The maximum advisory age runs from Pilot's last push, not from the source snapshot, and structural acceptance does not order source generations. A reduced but nonempty export still passes. These states can remove effective denial evidence, permit mirroring, or leave outdated advisory facts available to pruning. Compilation and consumer-load timestamps do not establish source freshness.

Mitigations

  • Implemented Maximum advisory age

    Consumers refuse advisory evidence older than the maximum advisory age, measured from Pilot's last push. An explicit advisories.maxAgeSeconds overrides the default, which is the shortest period the quarantine holds a version on that mount, minus 24 hours and never under 3 days, or 3 days when the mount has no quarantine rule. A seven-day hold in the quarantine gives six days, so a failure surfaces before the next cohort leaves the quarantine. Past the maximum age, the CVE and EPSS deny paths refuse even under onUnavailable: skip, and the maximum age applies separately from any rule's skip setting. An undated artifact refuses, and the Dredger withholds advisory-based deletions.

  • Implemented Systemic drop rate aborts the compile

    Pilot reads its own drop count for the run. When at least 16 entries drop and the drops are at least a tenth of the run, Pilot aborts the compile without publishing, so the proxy keeps its last-good database. That rate marks a mostly unusable feed, the shape of a compromised or truncated export.

  • Implemented Zero-output refusal

    After ecosystem filtering, Pilot counts the persisted advisory ranges. When none remain, including for an empty or wrong-ecosystem export, it logs ERROR and publishes nothing, before it writes success metadata. A rejected compile keeps the last-good bytes and timestamps, and a first rejection leaves no artifact. A nonempty but reduced export still passes.

  • Implemented EPSS success record

    Pilot records a successful EPSS enrichment in the artifact metadata, and a consumer with an EPSS-dependent rule refuses an artifact without that record.

  • Implemented Advisory source age alarm

    Pilot records where each advisory artifact's data came from and how old the sources say it is: the OSV and EPSS source authorities, each source's Last-Modified time, the newest OSV record's modified time, and the EPSS score date and model version. After each published compile it logs every source's age, and it logs at ERROR when a source is older than advisories.quietTime for that ecosystem or advisories.epssQuietTime for EPSS, seven days by default. Pilot ignores a record's modified date that is unreadable or later than the run's clock, logs that at WARNING, and keeps the record's ranges. The recorded source dates are diagnostics and do not decide any verdict.

  • Proposed Ordered advisory publication

    Planned in #1222. Pilot will refuse to overwrite a newer advisory artifact with older data, using metadata-only ordering and concurrency protection. A direct restore by the operator stays a permitted rollback. Independent source timestamps and intentional rule precedence stay unchanged.

  • Proposed Advisory size guard

    Planned in #1234. Pilot will refuse a compile whose output shrinks past a configurable guard, set per ecosystem and schema epoch, with a durable high-water mark where the store supports one. The starting defaults are a 2% ordinary shrink plus 12 bytes per prior scored range row, capped at an extra 5% on optional EPSS-loss transitions. An approved adjustment rebases the mark explicitly. Size is a heuristic, not proof of complete coverage.

  • Implemented EPSS failure blocks only EPSS-dependent ecosystems

    Pilot attempts the EPSS feed on every compile, and each ecosystem's resolved policy decides what a failed feed means. An ecosystem with an active DenyIfEpss rule fails the compile and publishes nothing, so its consumers keep their last accepted artifact. Any other ecosystem publishes its OSV data marked epss_status=unavailable, with no scores and no EPSS provenance, so new advisories and their fixes still reach its consumers. A consumer with an EPSS-dependent rule refuses that artifact. A one-shot compile of an ecosystem the configuration does not mount requires the feed. The decision reads Pilot's own configuration, so Pilot and the consumers must share one rule configuration.

  • Implemented Outage reporting and skipped-check evidence

    Each admission carries the configured checks it did not benefit from: a check that ran and could not vet under onUnavailable: skip, kept apart from a check a higher-precedence allow pre-empted, so no consumer reads an unreached check as passed. The public artifact gate logs a skipped check with the package, version, rule, and cause, once per package, version, and skipped rule set for the life of the advisory outage, cleared when the source recovers and capped at 4096 identities per ecosystem, and a trusted read of a mirrored copy runs no rules and logs nothing. The evidence lives in the decision and the log only, never in registry metadata. Apart from that, each ecosystem's advisory source has an outage monitor: the first evaluation that cannot consult the database (none loaded, an expired push, a lookup fault, or an open breaker) logs at ERROR, a continuing outage logs at ERROR again at most every 15 minutes, and recovery logs at INFO. A failed poll of the advisory store logs at ERROR at the first failure and again at most every 15 minutes, with recovery at INFO. A request never adds an outage line.

Assumptions

None recorded.

Threat 113: Advisory source URL credentials enter artifacts and sync logs

  • Elements: Écluse Pilot (advisory ingestion)
  • Category: Information disclosure (STRIDE)
  • Severity: Medium
  • Status: Mitigated
  • Flags: None

Description

Custom advisory source URLs can carry userinfo and query credentials. Pilot writes source identities into artifact metadata, and consumer sync writes that metadata into INFO logs, so a complete URL in either place would disclose its credentials. The default public feeds need no credentials.

Mitigations

  • Implemented Credential-free advisory source labels

    Pilot stores each advisory source as its host and port through authorityLabel, excluding path, userinfo, query, and fragment, and keeps the complete URL only for fetching. Consumer sync logs fixed fields, such as compile time, row count, push time, source authority, and source dates, and never arbitrary metadata, even from older artifacts. A malformed display value does not change artifact acceptance.

Assumptions

  • Valid

    Operators do not put credentials in configured endpoint URLs, because the boot configuration echo prints them. Artifacts and logs from Pilot builds older than the credential-free source labels can hold advisory source credentials, and the operator reviews them.

Threat 114: Advisory package identity mismatch bypasses policy

  • Elements: Écluse proxy
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

Advisory rules can query a package by its display spelling while identity comparisons use its canonical name. A PyPI package displayed as Flask_Thing could then miss an advisory stored as flask-thing, bypassing CVSS and EPSS denials and missing remediation evidence. The Dredger selects the canonical identity, but its shared rule query could miss the same advisory. This is a producer and consumer contract defect, independent of advisory truth or source-score trust.

Mitigations

  • Implemented Canonical advisory package identity

    Pilot canonicalises package names before it stores advisory segments: PyPI by PEP 503, while npm and RubyGems keep their spelling. Denial and remediation consumers query the canonical key, so the Dredger's candidate selection and the shared rules use one identity. Pilot canonicalises configured exports itself and does not rely on the OSV schema's normalisation rule. Schema epoch 4 marks this stored meaning, and consumers refuse any other epoch, epoch 3 included.

Assumptions

  • Valid

    Operators roll out an advisory artifact format change in order: they publish artifacts at the new schema epoch before they switch consumers, keep old objects for old consumers during the rollout, update exact-key IAM policies, and run the current Pilot build on every publisher. Écluse has no automatic migration or compatibility fallback, and an older Pilot build can republish evidence a newer build excludes.

Threat 115: Withdrawn OSV records remain active policy evidence

  • Elements: Écluse Pilot (advisory ingestion)
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

OSV exports retain withdrawn records and their affected ranges. If Pilot ignores withdrawal, an honest feed causes false advisory denials, invalid remediation exceptions, and eligible Dredger deletions. An existing artifact keeps the old evidence until a successful compile and sync replace it.

Mitigations

  • Implemented Zero-output refusal

    After ecosystem filtering, Pilot counts the persisted advisory ranges. When none remain, including for an empty or wrong-ecosystem export, it logs ERROR and publishes nothing, before it writes success metadata. A rejected compile keeps the last-good bytes and timestamps, and a first rejection leaves no artifact. A nonempty but reduced export still passes.

  • Implemented Withdrawn advisories excluded

    Pilot parses the optional withdrawal timestamp and excludes withdrawn records before it emits ranges or exact-version points, so a withdrawal removes both denial and remediation evidence while independent overlapping advisories still apply. A malformed timestamp follows malformed-record handling. Tests drive fixtures through local HTTP servers, the compiler, SQLite acceptance, sync, the shared rules, and the in-memory Dredger deletion path.

Assumptions

  • Valid

    Operators roll out an advisory artifact format change in order: they publish artifacts at the new schema epoch before they switch consumers, keep old objects for old consumers during the rollout, update exact-key IAM policies, and run the current Pilot build on every publisher. Écluse has no automatic migration or compatibility fallback, and an older Pilot build can republish evidence a newer build excludes.

Threat 116: Rejected artifact entries return through filename-based assembly

  • Elements: Écluse proxy
  • Category: Tampering (STRIDE)
  • Severity: High
  • Status: Mitigated
  • Flags: None

Description

An upstream PyPI index can declare the same filename on admitted and refused entries. Assembly by filename alone could restore a sibling that location or integrity admission removed, return the refused digest fields in the listing, and rebase a foreign URL to the local artifact route. This is a listing-admission mismatch, not direct foreign-host egress, downloaded malicious bytes, or a demonstrated bypass of package-manager verification.

Mitigations

  • Implemented Same-authority artifact locations

    A disallow-by-default same-authority policy applies to every upstream-declared artifact location, matched on host and port, with the hosts an ecosystem serves artifact bytes from as its one equivalence. Écluse upgrades a legacy http location to https on the same host, and drops and records a file whose scheme or authority the policy refuses. The projection and the download gate read one definition of the check, so a refused file is absent from the served listing, and a release drops when none of its files survive.

  • Implemented Source-local artifact entry keys

    Artifact extraction assigns each entry a source-local key before lenient drops. PyPI preserves original array positions in full and selected reads, including gaps left by discarded files. npm uses version keys. Merge and assembly match the winning source, its complete upstream-byte digest and the admitted key. Missing, ambiguous or mismatched identities contribute no entry. Duplicate admitted files retain their source order and supported fields. Unknown fields are omitted rather than relayed as source spans. Incremental hashing covers the complete successful decompressed response, including omitted fields. No extra serve-time hash, persistent identity or schema epoch is introduced.

Assumptions

None recorded.

Threat 118: Crafted string collisions make one metadata read quadratic

  • Elements: Écluse proxy, Écluse Dredger
  • Category: Denial of service (STRIDE)
  • Severity: Low
  • Status: Mitigated
  • Flags: None

Description

Each metadata read keeps one copy of every key and string its releases or files repeat, in a table that lasts only for that read. A package author who publishes a packument or index with many strings that collide under the table's hash would make the read of that package take quadratic time. The metadata body limit bounds the input, and the request timeout bounds the serve path. A package is read only after an internal client requests it: the proxy reads it for that request, and once it is in a store the Dredger rereads it on each sweep under the body limit, without the request timeout. An operator can deny the package by identity once it causes harm, and the Dredger then deletes it.

Mitigations

  • Implemented Fail-closed document limits

    Fail-closed limits still bound metadata body bytes, version count, artifact count and retained structure depth (ECLUSE_LIMITS__MAX_RESPONSE_BYTES, ECLUSE_LIMITS__MAX_VERSION_COUNT, ECLUSE_LIMITS__MAX_ARTIFACT_COUNT, ECLUSE_LIMITS__MAX_NESTING_DEPTH). The reader counts decompressed bytes and stops an oversized response. Dredger reads store package documents under the same body bound. npm and PyPI extract supported fields by walking the tokens of the maintained json-stream lexer, checking structural counts during extraction instead of projecting a complete source document first. Skipped fields do not become decoded values. A release or file the projection drops, and each repeat of a member within an object, never enters the read's table of shared strings, and a repeated member is dropped as it arrives. Extraction does not establish whole-document JSON validity. Supported fields still materialise, and listings retain useful release and artifact results. Body limits and structural limits remain separate from memory admission. Neither streaming nor the response cap proves a bound on the resulting heap. Advisory-backed rules still perform work for each evaluated version.

  • Implemented Single-flight requests, the upstream progress floor, and request timeout

    Single-flight joins eligible concurrent reads or renders for the same key onto one active computation, independently of retention. The local provider retains no full metadata, but active full reads still coalesce. Selected-version and assembled-response retention remain available through that provider. A per-request timeout limits each request. Every transfer over a registry protocol, the artifact stream relayed to a client included, keeps a progress floor of request and response body bytes together, at least limits.minProgressBytes (1 MiB by default) within each limits.progressWindow seconds (10 by default) spent waiting on the upstream. A transfer below the floor, a silent one included, fails with the transport timeout, and its connection closes rather than returning to the pool. Each upstream exchange of a served request also ends at the request timeout less a fixed 10-second margin, so a slow fetch fails before the request timeout, and the requests coalesced onto it receive the same fault and release their admission. The mirror worker and the Dredger have no such cap. The timeout is a transport fault like any other, and a private read that fails this way for a name that is not first-party falls back to the rule-gated public document, as any private transport fault does. Distinct keys do not coalesce, and useful results still occupy memory until their consumers release them.

  • Implemented Per-read keyed hash for the metadata intern table

    The per-document intern table keys its hash with SipHash-1-3 under a random key drawn for each read, so an attacker cannot precompute colliding strings and a read's table stays linear.

Assumptions

None recorded.