Skip to content

Credential classes and the redaction filter

Two halves of one question: what grants access, and can the program print it? The census names every class of access-granting value with a ceiling and a leak class; the redaction filter is what keeps any of them out of the program's output. Instance secrets is where a value is stored and how it is verified.

Classes and instances

nerthus secrets census lists classes: a session cookie, an enrolment token, a corpus deploy token, each with its issuer, holder kind, leak class, ceiling and tier. The inventory in Nerthus.Infra lists instances: where one particular value lives and who can be asked to rotate it. An inventory row names its class, and nerthus secrets verify refuses the two when they disagree.

nerthus secrets census                 # every class
nerthus secrets census --minted        # only what Platform mints

The class list exists because Platform mints values (session cookies, API keys, instance keys, site data keys) that no census of an issuer's API can see. It is also checked from the consumer's end: tests/test_secrets_census.py enumerates every credential-shaped name the package reads and refuses one with no class.

The leak class is not a severity

It answers one question: after the value is revoked, is the incident over?

Leak class Meaning
rotate-and-done the value only unlocks a live service. Rotate, read the audit, done.
data-is-out the value decrypts or hands over data the holder may already have taken. Rotate and decide about disclosure.

A corpus deploy token is data-is-out: one git clone is the whole corpus and its history. The backup age key decrypts every dump ever taken, and a new key takes nothing back. A runner's cache credential is rotate-and-done and still dangerous, because it can write a cache the next pipeline executes; that is a severity question, which this field does not answer.

Ceilings

A ceiling has four fields: an absolute life, an idle life, a grace window for a rotation with signatures in flight, and single_use. A session has both clocks, so a laptop left open and a stolen cookie kept for a month get different answers; Ceiling.expires_at returns the earlier. nerthus secrets verify refuses a Platform-issued inventory row with no ceiling.

A ceiling in the census is a declaration: the code that mints a class mints against it, and census.due() is what a rotation reminder asks.

Recipients: one kind per file

A sops file has recipients of exactly one kind: a role's instances, one person, or one external consumer. A file encrypted to a role and a person has a rotation with two audiences.

recipients:
  dev-site:
    key: age1…                 # the public half; nothing secret is in this file
    kind: role-instances
    instance_kind: site        # site, client or isolated
    who: the dev site's instances
sops_files:
  ci/lore.yaml:
    what: the lore corpus deploy token
    recipient_kind: role-instances
    per_instance: false        # a client instance may be a recipient only when this is true

A client instance is never pushed into; it may be a recipient only of a file marked per_instance. verify reports sops-file-mixed-kinds, sops-file-wrong-kind, sops-file-client-recipient, sops-file-unknown-recipient and sops-file-unreadable, each its own finding. The recipient list is cleartext in every sops file, so this whole rule is checked by a machine that can decrypt none of them.

tier: root

A credential that can mint others lives in a person's keychain or on a hardware key: never in sops, never on a host, never in CI. verify reports credential-root-in-vault when a tier: root row's value is in the vault, and does not probe it: using it would be one more act with a credential in the wrong place.

The redaction filter

nerthus.common.redact: three layered defences, each with its blind spot.

  1. Registered values. Anything that decrypts or reads a secret calls redact.register(), and every sink masks that exact string; it is the only defence that works on a password. The sops store registers every value it decrypts. Blind: a value nobody registered.
  2. Self-describing shapes. The issuers' prefixes (glpat-, gldt-, AGE-SECRET-KEY-1, an OpenSSH header with a body, AKIA) and the ones Platform mints (nrth_ak_, nrth_en_, nrth_sc_), read from the census, so a new minted class is redacted from the moment its row exists. Blind: anything merely long and random.
  3. Context. A value under a key whose name says it is a secret, an Authorization or Cookie header, --password <value> on a command line, the userinfo half of a URL. Blind: a secret logged bare beside an innocent name.

A key is secret by its last word. POSTGRES_PASSWORD holds one; password_file holds a path an operator needs to read. A secret word is also matched as a suffix of the last word, because PGPASSWORD is one word to any splitter.

There is no bare-entropy rule over free text. A threshold high enough to spare hashes and UUIDs still masks every age recipient, every base64 envelope in a sops document and every base64 digest the program prints on purpose. high_entropy_runs() exposes the heuristic for an audit that sweeps an artefact, where a false positive costs a glance.

The sinks

redact.install() runs at the CLI entry point and covers sys.stdout and sys.stderr (line-buffered, so a value written in two calls is masked whole), every handler on the root logger (filter and formatter, because a traceback is rendered at format time), sys.excepthook and threading.excepthook. The suite asserts redact.unattached_handlers() is empty after a real boot.

Typer's pretty traceback is off. rich renders a frame's local variables through a console of its own, which printed a token held in a local variable; with it off, an uncaught exception reaches sys.excepthook, which the filter owns.

Environments and files

A secret in an environment variable is a secret in docker inspect, in the compose file that set it, in the shell history of whoever ran it and in every ps on the host.

docker inspect $(docker ps -q) | nerthus secrets scan-env

Exit 0 clean, 2 on a finding, 3 when the input could not be read. It reports a value that is a credential by its shape, a name that says its value is one (even when empty), and a URL with a password in it. image:smoke runs it over the built instance image with a planted POSTGRES_PASSWORD as its positive control.

The package passes no credential on argv: tools/sync.sh names the index and lets uv read the credential from UV_INDEX_NERTHUS_*, and nerthus db psql, pg_dump and every health query hand libpq the password as PGPASSWORD rather than inside a URL.

Handling a credential in a session

  • Do not type it. nerthus secrets set NAME --file - reads stdin; sops opens an editor.
  • If you must, put a space before the command, with setopt histignorespace (zsh) or HISTCONTROL=ignorespace (bash); neither is on by default. Or unset HISTFILE for the session.
  • A token that reached a history file, a session log or a recorded terminal is a token to rotate, not to delete from the file.
  • Never echo $TOKEN, never sops -d to a terminal, never glab auth status -t. Prove a token works by using it.
  • A census over an API reads it through a classifier that returns a shape and a length, never the raw response: GitLab's variables endpoint returns values, and masking is a property of job logs, not of the API. A gitignored file is a file to grep -c, never to cat.