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:
- Mitigated: The code carries the compensating control. Operator responsibilities that also apply are recorded as assumptions.
- Accepted risk: Écluse retains the risk on purpose. It follows from a trust assumption or a deliberate operator trade-off, not from a missing implementation.
- Open: Écluse plans a fix but the code does not carry it yet. A threat stays Open until its code lands, so a milestone entry never reads as done.
- Transferred: Écluse has no control of its own, and the operator's configuration carries the whole defence. The threat's assumptions say what the operator owns.
Security posture explains the security invariants the code upholds, and why.
Data-flow diagram#
Threat register#
| Number | Title | Elements | Category | Severity | Status |
|---|---|---|---|---|---|
| 1 | Forwarded caller credentials aggregated in proxy memory | Écluse proxy | Information disclosure (STRIDE) | High | Mitigated |
| 2 | Chokepoint exhaustion via pathological client or upstream input | Écluse proxy | Denial of service (STRIDE) | Medium | Mitigated |
| 3 | Off-by-default edge auth assumes a sound network boundary | install and publish (caller credential) | Spoofing (STRIDE) | Medium | Mitigated |
| 4 | Caller credential leak to the public upstream | public metadata and artifact fetch (anonymous) | Information disclosure (STRIDE) | High | Mitigated |
| 5 | SSRF via crafted identifier or upstream-declared dist.tarball | public metadata and artifact fetch (anonymous) | Elevation of privilege (STRIDE) | High | Mitigated |
| 6 | Package shadowing via first-party publish | relay first-party publish (publisher or static publication token) | Tampering (STRIDE) | High | Mitigated |
| 7 | Mirror-write credential is a standing privilege over the trusted store | publish mirrored artifact (mirror worker identity) | Elevation of privilege (STRIDE) | High | Mitigated |
| 8 | SSRF to the instance-metadata credential endpoint | Écluse proxy, Écluse mirror worker | Elevation of privilege (STRIDE) | Medium | Mitigated |
| 9 | Cross-client disclosure of a private package via shared cache | Écluse proxy | Information disclosure (STRIDE) | High | Mitigated |
| 10 | Registry collapse erases provenance and per-store policy | Registry B: mirror target (public-derived store) | Repudiation (STRIDE) | Medium | Mitigated |
| 11 | Undetected artifact substitution across upstreams | Registry C: private upstream (pull-through read endpoint) | Tampering (STRIDE) | High | Mitigated |
| 12 | Upstream registry forges its own server-asserted metadata (e.g. a backdated publish time) | Public registry (npm, PyPI) | Tampering (STRIDE) | High | Accepted risk |
| 13 | Mirrored versions remain trusted until eligible pruning succeeds | Registry B: mirror target (public-derived store) | Tampering (STRIDE) | Medium | Mitigated |
| 14 | SSRF via the worker back-fill fetch (a blind sink) | back-fill artifact fetch (untrusted) | Elevation of privilege (STRIDE) | Low | Mitigated |
| 15 | Private-upstream aggregation admits the public registry, bypassing the gate | Registry C: private upstream (pull-through read endpoint) | Tampering (STRIDE) | High | Transferred |
| 16 | Connect-time reachability/timing oracle for an attacker-controlled allowlisted DNS | public metadata and artifact fetch (anonymous) | Information disclosure (STRIDE) | Low | Accepted risk |
| 17 | Pilot container-role privilege escalation | Écluse Pilot (advisory ingestion) | Elevation of privilege (STRIDE) | High | Mitigated |
| 18 | Proxy compromised via tampered OSV database | Écluse proxy | Tampering (STRIDE) | High | Mitigated |
| 20 | Pathological OSV Payload (DoS) | Écluse Pilot (advisory ingestion) | Denial of service (STRIDE) | Medium | Mitigated |
| 21 | Massive Purge DoS | Écluse Dredger | Denial of service (STRIDE) | Medium | Mitigated |
| 22 | Mirror-write credential can be sent to a misconfigured registry target | publish mirrored artifact (mirror worker identity) | Information disclosure (STRIDE) | Medium | Mitigated |
| 23 | Package-name spoofing via invisible characters | install and publish (caller credential) | Spoofing (STRIDE) | Medium | Mitigated |
| 24 | Package-name typosquatting within the permitted character set | install and publish (caller credential) | Spoofing (STRIDE) | Medium | Accepted risk |
| 25 | Dredger inappropriately purges valid packages | Écluse Dredger | Denial of service (STRIDE) | Medium | Mitigated |
| 26 | Dredger container-role privilege escalation | Écluse Dredger | Elevation of privilege (STRIDE) | High | Mitigated |
| 28 | First-party data loss from collapsed registries | Écluse Dredger | Denial of service (STRIDE) | High | Mitigated |
| 29 | Publisher credential relayed to a misdirected publication target | relay first-party publish (publisher or static publication token) | Information disclosure (STRIDE) | Medium | Mitigated |
| 101 | Oracle Blackout / Supply Chain DoS via OSV.dev compromise | Écluse Pilot (advisory ingestion) | Spoofing (STRIDE) | High | Accepted risk |
| 102 | Accidental permanent deletion of registry data | Écluse Dredger | Elevation of privilege (STRIDE) | Critical | Mitigated |
| 103 | Dependency confusion through a first-party namespace on the public upstream | public metadata and artifact fetch (anonymous) | Spoofing (STRIDE) | High | Mitigated |
| 104 | Configured deletion consent stays valid when the store it names changes | Écluse Dredger | Elevation of privilege (STRIDE) | Medium | Accepted risk |
| 105 | Store-supplied path components steer a credential-bearing delete | delete denied versions | Elevation of privilege (STRIDE) | Medium | Mitigated |
| 106 | Cold-start advisory availability depends on S3 | Advisory store (S3) | Denial of service (STRIDE) | Medium | Mitigated |
| 107 | Downstream cleanup gains authority over unrelated private versions | delete retained copies from the private read cache | Elevation of privilege (STRIDE) | High | Mitigated |
| 108 | Partial downstream cleanup leaves revoked content available | delete retained copies from the private read cache | Tampering (STRIDE) | High | Mitigated |
| 109 | An outdated Dredger policy deletes the only remaining package copy | Écluse Dredger | Denial of service (STRIDE) | Medium | Accepted risk |
| 110 | An admitted mirror transfer can finish after an advisory changes | publish mirrored artifact (mirror worker identity) | Tampering (STRIDE) | Low | Accepted risk |
| 111 | Incorrect EPSS source scores become authoritative policy evidence | Écluse Pilot (advisory ingestion) | Spoofing (STRIDE) | High | Open |
| 112 | Stale or incomplete advisory snapshots remain usable policy evidence | Advisory store (S3) | Tampering (STRIDE) | Medium | Open |
| 113 | Advisory source URL credentials enter artifacts and sync logs | Écluse Pilot (advisory ingestion) | Information disclosure (STRIDE) | Medium | Mitigated |
| 114 | Advisory package identity mismatch bypasses policy | Écluse proxy | Tampering (STRIDE) | High | Mitigated |
| 115 | Withdrawn OSV records remain active policy evidence | Écluse Pilot (advisory ingestion) | Tampering (STRIDE) | High | Mitigated |
| 116 | Rejected artifact entries return through filename-based assembly | Écluse proxy | Tampering (STRIDE) | High | Mitigated |
| 118 | Crafted string collisions make one metadata read quadratic | Écluse proxy, Écluse Dredger | Denial of service (STRIDE) | Low | Mitigated |
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
Secrettype, whoseShowinstance 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 anAuthorizationheader, although the WAI layer records benign headers such asUser-Agent. A regression test keeps theAuthorizationheader 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 eachlimits.progressWindowseconds (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.authTokenis 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) requiresserver.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=0on 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=0on 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_RANGESadds 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) requiresserver.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
firstPartydeclaration, 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-evildoes 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
codeArtifactthe 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. Underregistryandverdacciothe 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 holdss3:PutObjecton the advisory prefix, the proxy holdss3:GetObjectonly, 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=0on 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. Underregistryandverdaccio, 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 acodeArtifactURL 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
mirrorTargetholds 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.integritybefore 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
codeArtifactthe 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. Underregistryandverdacciothe 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 holdss3:PutObjecton the advisory prefix, the proxy holdss3:GetObjectonly, 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.integritybefore 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, orDenyIfEpss, 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.maxAgeSecondsoverrides 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 underonUnavailable: 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
appliesTokey 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-configlist each mount's rules by rule type with the phases each applies at, and print each configuredappliesTokey 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=0on 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
mirrorTargetholds 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.integritybefore 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
codeArtifactthe 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. Underregistryandverdacciothe 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 holdss3:PutObjecton the advisory prefix, the proxy holdss3:GetObjectonly, 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 = ONandPRAGMA 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, runsPRAGMA 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
codeArtifactthe 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. Underregistryandverdacciothe 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 holdss3:PutObjecton the advisory prefix, the proxy holdss3:GetObjectonly, 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. Underregistryandverdaccio, 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 acodeArtifactURL 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
mirrorTargetholds 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
codeArtifactthe 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. Underregistryandverdacciothe 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 holdss3:PutObjecton the advisory prefix, the proxy holdss3:GetObjectonly, 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, orDenyIfEpss, 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 (
DeletePackageVersionson 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 ispermitDeletionin 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.maxAgeSecondsoverrides 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 underonUnavailable: 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 ispermitDeletionin 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
codeArtifactthe 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. Underregistryandverdacciothe 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 holdss3:PutObjecton the advisory prefix, the proxy holdss3:GetObjectonly, 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 (
DeletePackageVersionson 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) requiresserver.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=0on 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, orDenyIfEpss, 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 (
DeletePackageVersionson 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 ispermitDeletionin 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.
Threat 104: Configured deletion consent stays valid when the store it names changes
- 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 ispermitDeletionin 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
verdacciotag 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'spermitDeletionkey is the one evidence of consent and of the store's classification. The owner accepts that a re-pointedverdacciotarget 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=0on 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.maxAgeSecondsoverrides 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 underonUnavailable: 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
DeletePackageVersionson B and C, neverDisposePackageVersions,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
codeArtifactthe 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. Underregistryandverdacciothe 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 holdss3:PutObjecton the advisory prefix, the proxy holdss3:GetObjectonly, 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
appliesTokey 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, orDenyIfEpss, 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 (
DeletePackageVersionson 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 ispermitDeletionin 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
appliesTokey 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.integritybefore 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.maxAgeSecondsoverrides 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 underonUnavailable: 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.maxAgeSecondsoverrides 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 underonUnavailable: 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.quietTimefor that ecosystem oradvisories.epssQuietTimefor 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 eachlimits.progressWindowseconds (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.