Curation
The curation layer provides fine-grained package access control for proxy registries. It evaluates every download request against a chain of filters (namespace isolation, blocklist, allowlist, min-release-age) and can hold newly fetched artifacts in a digest quarantine.
Overview
Section titled “Overview”Curation answers the question: should this package be allowed into my organization?
A download request is checked in this order:
- Namespace isolation — refuses to fetch internal package names from upstream (always active, even in
offmode) - In
offmode, the request is allowed here - Bypass token — a request carrying the token skips the filters below
- Blocklist — blocks packages matching explicit deny rules
- Allowlist — default-deny: only packages explicitly listed are allowed
- Min-release-age — blocks packages younger than a threshold
Each filter returns one of three decisions:
- Allow — request proceeds
- Block — request is denied (403 in enforce mode)
- Skip — no opinion, pass to the next filter
The first Allow or Block decides. If all filters skip, the request is allowed.
The digest quarantine is a separate gate: it runs after an artifact has been fetched from upstream and before it is served.
| Mode | Behavior |
|---|---|
off | Curation disabled. All proxy requests pass through. Namespace isolation still active. |
audit | Filters evaluate and log decisions, but never block. Useful for testing rules before enforcement. |
enforce | Filters evaluate and block matching requests with a 403 response. Needs at least one active control (see below). |
In enforce mode NORA refuses to start (exit code 78) unless at least one of these is set: allowlist_path, blocklist_path, min_release_age, quarantine, internal_namespaces, require_integrity. An allowlist is not required.
Configuration
Section titled “Configuration”# Environment variableexport NORA_CURATION_MODE=audit[curation]mode = "audit" # "off", "audit", "enforce"A filter that fails blocks the request (fail-closed). The on_failure setting accepts only "closed", the default; "open" is not implemented and NORA refuses to start with it.
Blocklist
Section titled “Blocklist”The blocklist is a JSON file containing deny rules. If any rule matches a package request, it is blocked.
File format
Section titled “File format”{ "version": 1, "rules": [ { "registry": "npm", "name": "event-stream", "version": "3.3.6", "reason": "CVE-2018-16492: malicious dependency flatmap-stream" }, { "registry": "*", "name": "colors", "version": "1.4.1", "reason": "Maintainer protest: infinite loop in v1.4.1" }, { "registry": "pypi", "name": "colourama", "version": "*", "reason": "Typosquatting: malicious clone of colorama" }, { "registry": "maven", "name": "org.log4j.**", "version": "*", "reason": "Block all log4j packages pending security review" } ]}Pattern matching
Section titled “Pattern matching”The registry, name, and version fields support simple glob patterns:
| Pattern | Matches |
|---|---|
* | Everything |
foo* | Plain prefix (starts_with): foo, foobar, foo-baz. Not separator-aware — com.acme* also matches com.acmecorp. For namespace isolation prefer .** / /** below. |
*foo | Suffix: foo, barfoo |
foo.** | Hierarchical prefix with a dot or Maven colon separator: foo, foo.bar, foo.bar.baz, and foo:artifact (Maven groupId:artifactId) |
foo/** | Hierarchical prefix with slash separator: foo, foo/bar, foo/bar/baz (Go modules, Terraform ns/type) |
exact | Exact string match |
Enable the blocklist
Section titled “Enable the blocklist”export NORA_CURATION_MODE=enforceexport NORA_CURATION_BLOCKLIST_PATH=/etc/nora/blocklist.json[curation]mode = "enforce"blocklist_path = "/etc/nora/blocklist.json"Generating the blocklist
Section titled “Generating the blocklist”NORA keeps no advisory database (ADR-2), so the blocklist is a file you own.
Hand-maintaining it against thousands of advisories a week does not scale, and
that is what Dozor is for: it compiles the OSV feed and
an inventory of what your registry holds into exactly this file. NORA needs no
changes — only blocklist_path.
Allowlist
Section titled “Allowlist”The allowlist is a default-deny list: only packages explicitly listed are allowed through. This is the strictest form of curation.
File format
Section titled “File format”{ "version": 1, "entries": [ { "registry": "npm", "name": "lodash", "version": "4.17.21", "integrity": "sha256:abc123def456...", "integrity_source": "upstream" }, { "registry": "npm", "name": "express", "version": "4.18.2" }, { "registry": "cargo", "name": "serde", "version": "1.0.203", "integrity": "sha256:789abc...", "integrity_source": "local" } ]}Fields:
registry— registry type:npm,pypi,maven,cargo,docker,go,gems,terraform,ansible,nuget,pub,conan,cpan,hf(the repository id asname, the commit asversion)name— exact package nameversion— exact version string
Names and versions are compared in the form each registry resolves them (for example, PEP 503 names for PyPI and lowercase ids for NuGet), so an entry holds however a client spells the package. The same applies to blocklist rules and internal namespaces.
integrity(optional) — SHA-256 hash for post-download verificationintegrity_source(optional, informational) —upstream,local, ormanual
Enable the allowlist
Section titled “Enable the allowlist”export NORA_CURATION_MODE=enforceexport NORA_CURATION_ALLOWLIST_PATH=/etc/nora/allowlist.jsonIf allowlist_path is set in enforce mode, the file must exist: NORA refuses to start otherwise (exit code 78).
Integrity verification
Section titled “Integrity verification”When require_integrity=true, every allowlist entry must include an integrity hash. After downloading an artifact from upstream, NORA computes its SHA-256 and compares it against the allowlist entry. A mismatch results in a block.
export NORA_CURATION_REQUIRE_INTEGRITY=trueThis protects against upstream supply chain attacks where a package is replaced after being vetted.
Namespace Isolation
Section titled “Namespace Isolation”Namespace isolation prevents dependency confusion attacks by blocking proxy requests for packages that match internal namespace patterns. This filter is always active, even when curation mode is off.
export NORA_CURATION_INTERNAL_NS="@mycompany/*,com.mycompany.**,github.com/myorg/**"[curation]internal_namespaces = [ "@mycompany/*", # npm scoped packages "com.mycompany.**", # Maven groupId hierarchy "github.com/myorg/**", # Go modules]When a request matches an internal namespace pattern, NORA never fetches it from upstream. A package published to NORA under an internal name is served from local storage; without a local copy the request is refused with 403 (X-Nora-Rule: namespace), and Docker answers 404 MANIFEST_UNKNOWN.
Min-Release-Age Filter
Section titled “Min-Release-Age Filter”Block packages that were published less than a configurable time ago. This protects against supply chain attacks that exploit the window between package publication and community review.
Global Setting
Section titled “Global Setting”export NORA_CURATION_MIN_RELEASE_AGE=7dexport NORA_CURATION_QUARANTINE=enforceexport NORA_CURATION_QUARANTINE_TTL=7d[curation]min_release_age = "7d"quarantine = "enforce"quarantine_ttl = "7d"Durations use h (hours), d (days) and w (weeks), and can be combined: 12h, 7d, 2w, 1w2d. Other units, such as 30m, are rejected: with mode = "enforce" NORA refuses to start, in other modes it logs Invalid min_release_age and runs without the filter.
Per-Registry Overrides
Section titled “Per-Registry Overrides”Different registries may warrant different age thresholds. A per-registry value applies to its own registry, with or without the global min_release_age (since NORA 1.4.0; before it, a per-registry value took effect only beside a global one). The global value is the default for registries without their own, and a registry’s own value wins in its registry.
On an enabled proxy registry a per-registry value needs a quarantine of that registry: its own [curation.<registry>] quarantine or the global quarantine. A quarantine on another registry does not count, since it holds none of this registry’s downloads; without one NORA refuses to start, naming the key:
[curation]min_release_age = "7d" # default for registries without their ownquarantine = "enforce"
[curation.npm]min_release_age = "3d" # npm packages: 3 days
[curation.pypi]min_release_age = "14d" # PyPI packages: 14 days
[curation.cargo]min_release_age = "1d" # Cargo crates: 1 dayEnvironment variables follow the pattern NORA_CURATION_{REGISTRY}_MIN_RELEASE_AGE:
export NORA_CURATION_NPM_MIN_RELEASE_AGE=3dexport NORA_CURATION_PYPI_MIN_RELEASE_AGE=14dexport NORA_CURATION_CARGO_MIN_RELEASE_AGE=1dexport NORA_CURATION_GO_MIN_RELEASE_AGE=7dexport NORA_CURATION_DOCKER_MIN_RELEASE_AGE=2dEvery registry except Raw and Hugging Face has a per-registry section (15 in all): npm, pypi, cargo, go, docker, maven, gems, terraform, ansible, nuget, pub (Dart), conan, rpm, deb, cpan. For rpm and deb only the quarantine settings take effect: their proxied repositories are gated by the digest quarantine, not by the name-based filters. A min_release_age for rpm, deb or cpan is refused at startup, since it could never apply: cpan downloads carry no release date. Set their quarantine instead. A value for a registry that is not enabled holds nothing: NORA starts, warns, and does not count it as an active control in enforce mode.
How It Works
Section titled “How It Works”The filter judges a package’s age from a publish date and runs on artifact downloads, not on metadata requests:
- With
NORA_TRUST_UPSTREAM_DATES=true(server.trust_upstream_dates), it uses the release date the upstream reports where one is available: npm, PyPI, Cargo, Go, NuGet, Conan, pub.dev, Maven, RubyGems, Ansible and Terraform. Upstream dates are unsigned, which is why this is opt-in. - Otherwise it uses the time NORA cached the package’s metadata, where NORA has one.
- When no date is known, the filter skips and the decision is left to the digest quarantine.
A block names the rule and the age, for example "rule":"min-release-age","reason":"package is 0h old (minimum: 7d)".
The filter requires mode = "audit" or mode = "enforce" to take effect.
Digest Quarantine
Section titled “Digest Quarantine”The digest quarantine holds an artifact NORA has just fetched from upstream until it has been known for a configurable time. It keys on the artifact’s content digest and NORA’s own clock, so it needs no upstream date and cannot be spoofed by one. It covers every proxy registry.
export NORA_CURATION_QUARANTINE=enforce # off (default), observe, enforceexport NORA_CURATION_QUARANTINE_TTL=7d # default 14d[curation]quarantine = "enforce"quarantine_ttl = "7d"
[curation.docker]quarantine_ttl = "2d" # per-registry override| Mode | Behavior |
|---|---|
off | No quarantine (default) |
observe | Records first-seen digests and logs what would be held, serves everything |
enforce | Answers 403 for an artifact until it is older than quarantine_ttl |
quarantine_ttltakesh,dandwunits likemin_release_age; a value NORA cannot parse falls back to the default14d.- Per-registry overrides:
[curation.<registry>]quarantine/quarantine_ttl, orNORA_CURATION_<REGISTRY>_QUARANTINE/_QUARANTINE_TTL. - With
NORA_TRUST_UPSTREAM_DATES=true, an artifact’s first-seen time starts from its upstream release date where the registry reports one, so a package released long ago is served at once and only a recent one is held.
A held artifact gets a 403 with X-Nora-Quarantine: pending:
{ "error": "quarantine", "message": "held by quarantine policy on registry 'npm': artifact must age past the quarantine window before it is served", "detail": { "registry": "npm", "digest": "sha256:7b75c105...", "policy": { "control": "quarantine", "quarantine_ttl_secs": 604800 }, "quarantine_until": 1792489967, "remaining_secs": 604800 }}Bypass Token
Section titled “Bypass Token”For emergency situations (e.g., a critical package update is blocked by curation), a bypass token allows specific requests to skip curation filters. Namespace isolation still applies.
export NORA_CURATION_BYPASS_TOKEN="your-secret-token"Send the token in the X-Nora-Bypass-Token HTTP header:
curl -H "X-Nora-Bypass-Token: your-secret-token" \ https://registry.example.com/npm/lodash/-/lodash-4.17.22.tgzEach bypass is logged as a warning, [SECURITY] Curation bypassed via token. Use bypass tokens sparingly and rotate them regularly.
Using Both Blocklist and Allowlist
Section titled “Using Both Blocklist and Allowlist”When both files are configured, the evaluation order is:
- Blocklist is checked first. If a package matches a blocklist rule, it is blocked regardless of allowlist status.
- Allowlist is checked second. If the package is not in the allowlist, it is blocked.
This means the blocklist acts as an overlay on top of the allowlist. You can approve a package in the allowlist and then later block a specific vulnerable version via the blocklist without modifying the allowlist.
[curation]mode = "enforce"blocklist_path = "/etc/nora/blocklist.json"allowlist_path = "/etc/nora/allowlist.json"CLI Tools
Section titled “CLI Tools”NORA includes CLI commands for working with curation files.
Validate
Section titled “Validate”Validate a blocklist or allowlist JSON file before deploying it:
nora curation validate /etc/nora/blocklist.jsonOutput:
OK: Valid blocklist — 4 rules [1] npm/event-stream@3.3.6 — CVE-2018-16492: malicious dependency flatmap-stream [2] */colors@1.4.1 — Maintainer protest: infinite loop in v1.4.1 [3] pypi/colourama@* — Typosquatting: malicious clone of colorama [4] maven/org.log4j.**@* — Block all log4j packages pending security reviewnora curation validate /etc/nora/allowlist.jsonOutput:
OK: Valid allowlist — 3 entries (2 with integrity) [1] npm/lodash@4.17.21 [hash] [2] npm/express@4.18.2 [3] cargo/serde@1.0.203 [hash]The validate command checks:
- JSON syntax is valid
- Schema version is
1 - Required fields are present
Explain
Section titled “Explain”Explain the curation decision for a specific package. Requires a running configuration (reads config.toml or env vars):
nora curation explain cargo:serde@1.0.203The package format is registry:name@version; the registry prefix is required, the version part is optional:
nora curation explain npm:lodashThe explain command loads the configured blocklist, allowlist, internal namespaces and min-release-age and simulates the filter chain, showing which filter produces the decision and why (with the blocklist above):
nora curation explain npm:event-stream@3.3.6Package: npm:event-stream@3.3.6Decision: Block { rule: "blocklist", reason: "CVE-2018-16492: malicious dependency flatmap-stream" }Decided by: blocklistBlocked Response Format
Section titled “Blocked Response Format”When curation blocks a request in enforce mode, the response is a JSON 403:
{ "error": "blocked_by_policy", "error_version": "v1", "context": { "rule": "blocklist", "reason": "CVE-2018-16492: malicious dependency flatmap-stream", "registry": "npm", "package": "event-stream", "version": "3.3.6" }, "hint": "Run: nora curation explain event-stream@3.3.6", "docs": "https://docs.getnora.dev/curation"}The hint omits the registry; add it when you run the command: nora curation explain npm:event-stream@3.3.6.
Response headers:
X-Nora-Decision: blockedX-Nora-Rule: blocklistX-Nora-Reason: CVE-2018-16492: ...
These headers allow CI/CD pipelines to detect and handle curation blocks programmatically. A quarantine hold has its own response format.
Observability
Section titled “Observability”The X-Nora-* headers are set on refused requests; an allowed download carries none.
| Header | Description |
|---|---|
X-Nora-Decision | blocked |
X-Nora-Rule | Which rule matched: blocklist, allowlist, namespace, min-release-age |
X-Nora-Reason | Human-readable reason for the decision |
X-Nora-Quarantine | pending on a quarantine hold |
Every refused download is recorded in the audit log as a curation_block entry (detail holds the rule and reason). In audit mode a would-be block is logged as [AUDIT] Download would be blocked.
Prometheus metrics: nora_curation_decisions_total{decision}, nora_quarantine_holds_total{registry,outcome} and nora_namespace_isolation_refused_total{registry} (see Prometheus Metrics).
Examples
Section titled “Examples”Block a single vulnerable package
Section titled “Block a single vulnerable package”{ "version": 1, "rules": [ { "registry": "npm", "name": "event-stream", "version": "3.3.6", "reason": "CVE-2018-16492" } ]}Allowlist-only mode (strict)
Section titled “Allowlist-only mode (strict)”Approve only vetted packages. Everything else is denied:
export NORA_CURATION_MODE=enforceexport NORA_CURATION_ALLOWLIST_PATH=/etc/nora/allowlist.jsonexport NORA_CURATION_REQUIRE_INTEGRITY=trueAudit mode for testing
Section titled “Audit mode for testing”Run in audit mode first to see what would be blocked without disrupting developers:
export NORA_CURATION_MODE=auditexport NORA_CURATION_BLOCKLIST_PATH=/etc/nora/blocklist.jsonexport NORA_CURATION_ALLOWLIST_PATH=/etc/nora/allowlist.jsonCheck logs for [AUDIT] Download would be blocked entries, then switch to enforce when satisfied.
Block all packages from a namespace
Section titled “Block all packages from a namespace”{ "version": 1, "rules": [ { "registry": "maven", "name": "org.apache.log4j.**", "version": "*", "reason": "Log4Shell family - use logback instead" }, { "registry": "go", "name": "github.com/untrusted-org/**", "version": "*", "reason": "Untrusted organization" } ]}See Also
Section titled “See Also”- Configuration Reference — all environment variables
- Authentication — user management and API tokens
- Audit Log —
curation_blockentries - Dozor — generates the blocklist from OSV