Skip to content

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.


Curation answers the question: should this package be allowed into my organization?

A download request is checked in this order:

  1. Namespace isolation — refuses to fetch internal package names from upstream (always active, even in off mode)
  2. In off mode, the request is allowed here
  3. Bypass token — a request carrying the token skips the filters below
  4. Blocklist — blocks packages matching explicit deny rules
  5. Allowlist — default-deny: only packages explicitly listed are allowed
  6. 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.


ModeBehavior
offCuration disabled. All proxy requests pass through. Namespace isolation still active.
auditFilters evaluate and log decisions, but never block. Useful for testing rules before enforcement.
enforceFilters 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.

Terminal window
# Environment variable
export NORA_CURATION_MODE=audit
config.toml
[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.


The blocklist is a JSON file containing deny rules. If any rule matches a package request, it is blocked.

{
"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"
}
]
}

The registry, name, and version fields support simple glob patterns:

PatternMatches
*Everything
foo*Plain prefix (starts_with): foo, foobar, foo-baz. Not separator-aware — com.acme* also matches com.acmecorp. For namespace isolation prefer .** / /** below.
*fooSuffix: 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)
exactExact string match
Terminal window
export NORA_CURATION_MODE=enforce
export NORA_CURATION_BLOCKLIST_PATH=/etc/nora/blocklist.json
[curation]
mode = "enforce"
blocklist_path = "/etc/nora/blocklist.json"

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.


The allowlist is a default-deny list: only packages explicitly listed are allowed through. This is the strictest form of curation.

{
"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 as name, the commit as version)
  • name — exact package name
  • version — 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 verification
  • integrity_source (optional, informational) — upstream, local, or manual
Terminal window
export NORA_CURATION_MODE=enforce
export NORA_CURATION_ALLOWLIST_PATH=/etc/nora/allowlist.json

If allowlist_path is set in enforce mode, the file must exist: NORA refuses to start otherwise (exit code 78).

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.

Terminal window
export NORA_CURATION_REQUIRE_INTEGRITY=true

This protects against upstream supply chain attacks where a package is replaced after being vetted.


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.

Terminal window
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.


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.

Terminal window
export NORA_CURATION_MIN_RELEASE_AGE=7d
export NORA_CURATION_QUARANTINE=enforce
export 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.

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 own
quarantine = "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 day

Environment variables follow the pattern NORA_CURATION_{REGISTRY}_MIN_RELEASE_AGE:

Terminal window
export NORA_CURATION_NPM_MIN_RELEASE_AGE=3d
export NORA_CURATION_PYPI_MIN_RELEASE_AGE=14d
export NORA_CURATION_CARGO_MIN_RELEASE_AGE=1d
export NORA_CURATION_GO_MIN_RELEASE_AGE=7d
export NORA_CURATION_DOCKER_MIN_RELEASE_AGE=2d

Every 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.

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.


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.

Terminal window
export NORA_CURATION_QUARANTINE=enforce # off (default), observe, enforce
export NORA_CURATION_QUARANTINE_TTL=7d # default 14d
[curation]
quarantine = "enforce"
quarantine_ttl = "7d"
[curation.docker]
quarantine_ttl = "2d" # per-registry override
ModeBehavior
offNo quarantine (default)
observeRecords first-seen digests and logs what would be held, serves everything
enforceAnswers 403 for an artifact until it is older than quarantine_ttl
  • quarantine_ttl takes h, d and w units like min_release_age; a value NORA cannot parse falls back to the default 14d.
  • Per-registry overrides: [curation.<registry>] quarantine / quarantine_ttl, or NORA_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
}
}

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.

Terminal window
export NORA_CURATION_BYPASS_TOKEN="your-secret-token"

Send the token in the X-Nora-Bypass-Token HTTP header:

Terminal window
curl -H "X-Nora-Bypass-Token: your-secret-token" \
https://registry.example.com/npm/lodash/-/lodash-4.17.22.tgz

Each bypass is logged as a warning, [SECURITY] Curation bypassed via token. Use bypass tokens sparingly and rotate them regularly.


When both files are configured, the evaluation order is:

  1. Blocklist is checked first. If a package matches a blocklist rule, it is blocked regardless of allowlist status.
  2. 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"

NORA includes CLI commands for working with curation files.

Validate a blocklist or allowlist JSON file before deploying it:

Terminal window
nora curation validate /etc/nora/blocklist.json

Output:

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 review
Terminal window
nora curation validate /etc/nora/allowlist.json

Output:

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 the curation decision for a specific package. Requires a running configuration (reads config.toml or env vars):

Terminal window
nora curation explain cargo:serde@1.0.203

The package format is registry:name@version; the registry prefix is required, the version part is optional:

Terminal window
nora curation explain npm:lodash

The 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):

Terminal window
nora curation explain npm:event-stream@3.3.6
Package: npm:event-stream@3.3.6
Decision: Block { rule: "blocklist", reason: "CVE-2018-16492: malicious dependency flatmap-stream" }
Decided by: blocklist

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: blocked
  • X-Nora-Rule: blocklist
  • X-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.


The X-Nora-* headers are set on refused requests; an allowed download carries none.

HeaderDescription
X-Nora-Decisionblocked
X-Nora-RuleWhich rule matched: blocklist, allowlist, namespace, min-release-age
X-Nora-ReasonHuman-readable reason for the decision
X-Nora-Quarantinepending 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).


{
"version": 1,
"rules": [
{
"registry": "npm",
"name": "event-stream",
"version": "3.3.6",
"reason": "CVE-2018-16492"
}
]
}

Approve only vetted packages. Everything else is denied:

Terminal window
export NORA_CURATION_MODE=enforce
export NORA_CURATION_ALLOWLIST_PATH=/etc/nora/allowlist.json
export NORA_CURATION_REQUIRE_INTEGRITY=true

Run in audit mode first to see what would be blocked without disrupting developers:

Terminal window
export NORA_CURATION_MODE=audit
export NORA_CURATION_BLOCKLIST_PATH=/etc/nora/blocklist.json
export NORA_CURATION_ALLOWLIST_PATH=/etc/nora/allowlist.json

Check logs for [AUDIT] Download would be blocked entries, then switch to enforce when satisfied.

{
"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"
}
]
}