Repository navigation
v0.7.0
evnx v0.7.0
Thirteen pull requests, and a cloud account is now fully self-service. A master
password can be changed and the change undone, an account can be deleted, and a
version can be removed from a vault's history — three things the server could
already do and nothing could reach.
Why this is 0.7.0 and not 0.6.1
Three changes alter behaviour rather than only adding to it, and in 0.x semver
puts a compatibility change in the minor position:
- Single-quoted values no longer expand.
'${FOO}'was substituted; it is now
literal, matching shell, dotenv, python-dotenv and godotenv. A file relying on
expansion inside single quotes changes meaning with no error, because both
forms parse. See Changed. - A password inside a connection string is now a finding. A repository whose
DATABASE_URLcarries credentials now failsevnx scan --severity highwhere it
passed. See Added. allow_actualnow does something. It was parsed and read by nothing; it is
implemented, and refuses the whole sync with exit 2 when a listed value looks
like a credential. See Added.
One build-level change worth knowing: a new net feature gates the HTTP stack, so
default = [] now means no network code is compiled in at all — scan,
validate, sync and diff are provably offline rather than offline by
convention. migrate and cloud both imply net, so no existing feature
selection changes meaning.
Added
-
evnx auth rotate-master-password— change your master password. The master
key is derived from the password, so changing it re-derives the key and re-wraps
every vault key under it, on your machine, in the same command. The server
receives a new verifier and a set of blobs it cannot read.⚠️ The server cannot tell a correct rotation from random bytes. That is the
guarantee, not a gap — it can check a payload is complete, never that it is
correct. So verification is mandatory rather than a flag: the re-sealed keypair
is re-opened and its public keys asserted unchanged, every new wrap is unwrapped
with the new master key and compared byte-for-byte with the key it came from, and
the wraps and keys are checked to be the same length and in the same order — they
are zipped, and an order that drifted would wrap each vault with its neighbour's
key and still verify pairwise. All of it happens before a byte is sent, so a
failure can say the account is untouched and mean it.Before acting it says how many keys move, how many shared-to-you vaults are
unaffected, that every other device will be signed out, and how many API tokens
exist. Tokens keep working — a token authenticates, it does not decrypt — but any
CI holding the master password as a secret fails until that secret is updated.
It does not re-key a vault, so a former member who kept a key is unaffected. (#92) -
evnx auth undo-password-change— and it takes no session, deliberately.
Whoever needs it is whoever the change locked out, and a session is exactly what
they do not have. Zero knowledge means the server can never be the recovery path,
so the old wraps are written to a0600file in the config directory before the
request: ciphertext, useless without the old password, and a complete offline
undo that does not depend on the server keeping anything. The server's own
snapshot window is the second way back. A wrong password and an address with
nothing to undo answer identically, so the command cannot be used to discover who
has an account. (#92) -
evnx auth delete-account. Prompts with the account named, takes the typed
email back, asks for a 2FA code only when 2FA is enabled, and clears the local
credential file afterwards. There is deliberately no--yes: a boolean flag that
deletes an account is one shell-history recall away from doing it.
--confirm-stdincovers the scripted case and still has to carry the address. A
409 is surfaced as the server's own prose, naming the vaults that block the
deletion, because reducing that to "conflict" strands someone with no idea what
to do next. (#81) -
evnx cloud delete-version. The client half of the quota remedy — the
server's quota refusal has started telling people to remove old versions, and
nothing could. Shows the version, its key count, size and push date before
asking, because "delete version 3" is not enough information to consent to.
--yesskips the prompt. The latest version is refused here as well as on the
server:pullandrunfetch the latest, so removing it would change what every
consumer of the vault gets without anyone asking for that. (#93) -
evnx update— how to upgrade, and whether to. evnx ships through nine
channels and none of them ever told anyone a release had happened. It detects how
the running binary was installed and prints that channel's upgrade command;
--checkasks GitHub Releases whether a newer release exists.Two deliberate limitations. Nothing runs unless asked — no command makes a
network request on its own, which is what keeps evnx usable air-gapped and in CI
with egress rules, and that is worth more than an upgrade nag. And nothing
self-replaces: nine package managers ship evnx, and a binary that overwrites
itself fights whichever one owns the file. An unrecognised install path prints
every command rather than guessing, becausebrew upgradeagainst a binary
Homebrew never installed fails unhelpfully.--checkreads GitHub Releases
because the tag is the only source true by construction — crates.io is published
by hand, npm publishes onworkflow_run, winget merges on Microsoft's schedule —
and the output says so every time it reports a newer version. (#88) -
scandetects a password embedded in a connection string.
DATABASE_URL=postgresql://admin:s3cr3t@db.prod.example.com:5432/mainscanned
clean, exit 0. The miss was structural: the provider patterns are all prefix
formats and a connection string has no prefix, the sensitive-key heuristic keys
off names containingsecret/token/keyandDATABASE_URLhas none of them,
and the entropy fallback scores a whole URL below threshold. A live database
password inDATABASE_URLis among the most common things in a.env.The character classes exclude
/,?and#so a match cannot run past the
authority into a path —https://host/users/me@example.comis an ordinary URL —
and a colon is required before the@, sopostgresql://reader@host/dbreads as
a bare username. Noaction_url: the place to rotate a database password is the
database. (#87) -
sync'sallow_actualis implemented, and guarded. It was documented with a
schema-table row and read by nothing. Refusing it outright would hard-fail every
config copied from that page, and the documented example uses it only for
non-secret keys —APP_NAME,ENVIRONMENT,LOG_LEVEL— where the real value
is the better example andsync --reversethen hands a teammate working
defaults. So it works, andcheck_allow_actualrefuses the whole sync before the
first write, naming every offending key at once with exit 2, when a listed value
looks like a credential.⚠️ The guard was built onDetectorRegistry::new(), which is not the set
scanuses:new()registers the two heuristics only, and the built-in patterns
arrive viawith_patterns, which only thescancommand called. So it was
relying on entropy and on sensitive names — enough forsk_live_…, not enough
for a connection string. Found by adding the rule above and watching the guard
fail to pick it up. It now useswith_patterns(builtin_rules()), which is what
its doc comment already claimed. (#86)
Fixed
-
A rejected login said "your session has expired".
evnx auth loginwith an
address that has no account reported "your session has expired. Run
evnx auth loginto sign in again" — nonsense twice over, since there was no
session and it names the command already running. It cannot say "no such
account":/srp/initanswers an unknown address with 200 and a fabricated salt
and verifier, so/srp/verifyrejects the proof with the same 401 a wrong
password gets, and the client genuinely does not know. The message now names both
possibilities, points at the likelier one, and says there is no password reset.The test that should have caught this had never reached the code: it asserted
onlyis_err(), and the mock pinned the status at 200 while switching the body
to an error document, so the client read an unparseable success. The status now
comes from the same deterministic function of the exchange as the body, so the
two cannot disagree. (#83) -
One credential in a source file counted as two secrets. A Stripe key in a
.pyreported twice — "High-entropy string" low and "Stripe Secret Key (LIVE)"
high, same value, same line — so--severitygates and CI totals saw one high
and one low. The same key in a.jsongave four. A non-.envline is scanned
by two paths that see different strings, and nothing reconciled them with each
other. Now reconciled per line, by value, using containment rather than equality:
the two paths disagree about where a value ends, so equality collapses the neatly
quoted case and leavesprefix-sk_live_…-suffixduplicated, which is the shape
real code is full of. Two different credentials on one line still stay two
findings, and order is first-seen so output is stable between runs. (#85) -
scantold you to rotate a key that is meant to be public. The footer added
in 0.6.0 said "revoke and reissue those keys — they have been readable by every
visitor to your site, so treat them as public, not merely leaked",
unconditionally. A Supabase anon key behindNEXT_PUBLIC_is designed to ship
in the browser and is protected by row-level security, not by secrecy: it is
public, and reissuing it is pointless work. The per-finding line already hedged
for exactly this case — "if this is not meant to be public, rename it to …" — and
the footer asserted over the top of it one line later.The footer now branches on the same discriminator the per-finding hedge uses: a
named provider format carries anaction_url, shape alone does not. With no
recognised provider among the public-prefixed findings it says evnx cannot tell a
publishable analytics key from an unrecognised secret, and asks the reader to
check before rotating. With one, the wording is unchanged — a Stripe key behind
VITE_is not ambiguous. Both branches still lead with renaming, which is right
either way, and the unprefixed path is untouched. Found by runningscanon a
real Next.js project rather than a fixture. (#82) -
syncproduced a git diff on a project whose.envhad not changed.
generate_placeholderiterated aHashMapand returned the first match, and
more than one pattern usually matches a key —STRIPE_SECRET_KEYmatches
SECRET,_KEY$and^STRIPE_at once. Rust randomisesHashMapiteration per
process, so the winner changed between runs and was written into.env.example, a
committed file. Eight identical runs produced four different answers. Order is now
exact key match, then longest pattern, then declaration order; every rung is
total, so the answer depends on the config file and the key and nothing else. The
test runs the binary eight times rather than looping in-process, because the
hasher is seeded once per process and the bug was invisible within a single
run. (#86) -
An uncompilable
syncpattern was silently skipped, so the key fell through
to the default and the template looked fine while ignoring the project's rules.
Now refused at load time, naming the pattern.$schemaanddescriptionwere
silently dropped by serde, so an operator could not tell accepted from
misspelled.naming_conventionis documented in the sync config but read from
--naming-policy, and now warns rather than vanishing. And
is_placeholder_valueignoredconfig.patterns, so a project whose convention
is<set-me>had all of its own placeholders reported as real values in the
dry-run preview. (#86) -
Every expansion error reported "line 0" — not a line in any file, in exactly
the case where a line number is what you need: a large.envthat suddenly will
not parse. Expansion is a separate pass over the assembled map, run after the
whole file is parsed, which is what makes a forward reference work; by then the
line numbers were gone. The parser now records where each key was written and
hands that to the pass. It reports the line of the key being expanded rather than
of the missing variable, which frequently does not exist anywhere. The
circular-expansion error shared the same hardcoded zero and is fixed with it. (#91) -
The published Scoop manifest was invalid JSON on every release since
e96bdb4."find": "^([a-f0-9]+)\s"—\sis not a JSON escape. The heredoc
that writes the manifest is unquoted, which it must be for${VERSION}to
substitute, so the shell ate one level of backslash and two arrived as one. It
needs four. Confirmed in the wild, not reasoned about: the published 0.6.0
manifest is rejected by bothjqandpython -m json.tool. Scoop'sparse_json
usesConvertFrom-Json -ErrorAction Stopand returns$nullon a throw, so on a
strict parser there was no manifest at all; a lenient parser leaves
^([a-f0-9]+)s, which does not match the real.sha256because the hex run is
followed by a space. Both outcomes are broken.The escape is one character; the gate is the fix. A new step asserts valid JSON,
the decodedfindvalue, asha256:<64 hex>hash and the version before
anything is pushed — decoded values, because over-escaping also parses, it just
means something else. This survived five releases because nothing between the
heredoc andgit pushever looked at the result. (#84) -
A 404 while fetching a checksum published
"hash": "sha256:". The release
workflow declares nodefaults.run.shell, so steps run underbash -ewithout
pipefail, and the exit status ofcurl | awkisawk's. Reproduced against a
real 404: exit 0 withoutpipefail, 22 with it. Fixed in the Scoop job and in
the Homebrew job, which had the same pattern across four assignments. (#84) -
Nothing validated the Homebrew formula before pushing it. Same shape as the
Scoop gap, and worse: a formula is Ruby, so an invalid one meansbrew install evnxfails outright for every macOS user until the next release. Now checks
ruby -c, that nosha256is empty, that everysha256is 64 hex characters,
and that the version matches the tag. Verified by parsing the step out of the YAML
and running it against four sabotages — an emptysha256, a six-character one, a
wrong version, and invalid Ruby — each of which it caught. (#89)
Changed
-
A secret containing a dollar sign can now be written. A token containing
${...}made the whole file fail to parse; one containing$NAME, whereNAME
was also a key, was silently replaced — substituting one of your secrets into
another, with no error. Neither\$nor$$nor single quotes prevented it.Two halves. Single quotes and backticks no longer expand: they are literal in
every respect now, matching shell, dotenv, python-dotenv and godotenv, and
turning "literal for backslashes but not for dollars" into one rule people can
hold. Whatever is between single quotes is the value. And\$is a literal
dollar in a double-quoted value, so expansion can be kept for the rest of the
string.⚠️ Breaking, and silently so: a file relying on expansion inside single
quotes changes meaning with no error, because both forms parse. Nothing in the
suite asserted the old behaviour — all 1039 tests passed before the new ones were
added — which reassures about internal reliance and warns that no test would have
caught it either way. (#90)
Installation
Linux / macOS (shell script)
curl -sSL https://raw.githubusercontent.com/urwithajit9/evnx/main/scripts/install.sh | bashHomebrew (macOS and Linux)
brew install urwithajit9/evnx/evnxScoop (Windows, user-local)
scoop bucket add evnx https://github.com/urwithajit9/scoop-evnx
scoop install evnxWinget (Windows, system-wide)
winget install urwithajit9.evnxnpm / Node.js
npm install -g @evnx/cliPython (pipx)
pipx install evnxCargo
cargo install evnx --all-features
cargo installcompiles from source withdefault = [], so plain
cargo install evnxgives you a binary without theauth,
vaultandcloudcommands. Every other channel above ships them
already — the binaries are built with--all-features.
Checksums
SHA256 checksums are provided for each binary. Verify after download:
sha256sum -c evnx-*.tar.gz.sha256What's Changed
- feat(auth): evnx auth delete-account by @urwithajit9 in #81
- fix(auth): a rejected login is not an expired session by @urwithajit9 in #83
- fix(release): the Scoop manifest must be valid JSON, and something must check by @urwithajit9 in #84
- fix(scan): one credential in a source file is one finding by @urwithajit9 in #85
- fix(sync): deterministic placeholders, and allow_actual does what it documents by @urwithajit9 in #86
- feat(scan): detect a password embedded in a connection string by @urwithajit9 in #87
- feat(update): evnx update — how to upgrade, and whether to by @urwithajit9 in #88
- fix(release): validate the Homebrew formula before pushing it by @urwithajit9 in #89
- fix(parser)!: single quotes are literal, and $ escapes expansion by @urwithajit9 in #90
- fix(parser): expansion errors name the line they were written on by @urwithajit9 in #91
- feat(auth): change your master password, and undo changing it by @urwithajit9 in #92
- feat(cloud): evnx cloud delete-version by @urwithajit9 in #93
- fix(scan): the public-prefix footer must hedge when the value may be public by @urwithajit9 in #82
- chore(release): 0.7.0 by @urwithajit9 in #94
Full Changelog: v0.6.0...v0.7.0
npm / Node.js
npm install -g @evnx/cli # global install
npx @evnx/cli --version # zero-install usage
npm install --save-dev @evnx/cli # project dev dependency