@stacksjs/env 0.75.185 → 0.75.186
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/encrypted-value.d.ts +17 -0
- package/dist/encrypted-value.js +1 -0
- package/dist/plaintext-env.js +1 -1
- package/dist/plaintext.d.ts +19 -24
- package/dist/plaintext.js +1 -1
- package/dist/plugin.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a value is dotenvx ciphertext rather than something usable.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, and a leaf, because of who needs it. The preloader and the
|
|
5
|
+
* parser ask this on a hot path, and `@stacksjs/database/runtime` reaches them
|
|
6
|
+
* through the env runtime entry. `plaintext.ts`, where this lived, also carries
|
|
7
|
+
* the committed-secret scanner, so importing it from there pulled that whole
|
|
8
|
+
* module into the database runtime's static graph, which
|
|
9
|
+
* `database/tests/runtime-entry.test.ts` forbids and which turned main red for
|
|
10
|
+
* three releases (stacksjs/stacks#2904).
|
|
11
|
+
*
|
|
12
|
+
* `encrypted:` is the current envelope (`encrypted:v2:` with the versioned
|
|
13
|
+
* header); `enc:` is the short alias. One definition, here: it was written out
|
|
14
|
+
* four separate times across this package before it was given a home, and
|
|
15
|
+
* consolidating it into the larger module is what cost the graph.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isEncryptedValue(value: string | undefined | null): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isEncryptedValue(value){return typeof value==="string"&&(value.startsWith("encrypted:")||value.startsWith("enc:"))}
|
package/dist/plaintext-env.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{isEncryptedValue}from"./
|
|
1
|
+
import{isEncryptedValue}from"./encrypted-value";export function plaintextEnv(values,encryptedKeys=Object.keys(values)){const ciphertext=encryptedKeys.map((key)=>values[key]).filter((value)=>isEncryptedValue(value));return Object.fromEntries(Object.entries(values).filter(([,value])=>value===void 0||!ciphertext.some((encrypted)=>value.includes(encrypted))))}
|
package/dist/plaintext.d.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
import { isEncryptedValue } from './encrypted-value';
|
|
2
|
+
/**
|
|
3
|
+
* Find values in a parsed env file that should have been encrypted.
|
|
4
|
+
*
|
|
5
|
+
* Returns key names, never values. Empty values, `${VAR}` expansions, dotenvx
|
|
6
|
+
* key metadata and anything already encrypted are skipped.
|
|
7
|
+
*/
|
|
8
|
+
export declare function plaintextSecrets(values: Record<string, string>, options?: PlaintextScanOptions): PlaintextFinding[];
|
|
9
|
+
/**
|
|
10
|
+
* Whether an env file is committed to git and therefore in scope.
|
|
11
|
+
*
|
|
12
|
+
* Enumerating with `git ls-files` rather than a hardcoded
|
|
13
|
+
* production/staging/ci list, because `.gitignore` un-ignores three names but
|
|
14
|
+
* `.env.development` is tracked as well - no ignore rule happens to match it.
|
|
15
|
+
* A hardcoded list would have skipped a file that is just as public.
|
|
16
|
+
*/
|
|
17
|
+
export declare function trackedEnvFiles(gitLsFilesOutput: string): string[];
|
|
1
18
|
/**
|
|
2
19
|
* Unencrypted secrets in a committed env file.
|
|
3
20
|
*
|
|
@@ -44,30 +61,6 @@
|
|
|
44
61
|
* into CI logs, terminal scrollback and issue trackers - which is the thing
|
|
45
62
|
* being prevented.
|
|
46
63
|
*/
|
|
47
|
-
/**
|
|
48
|
-
* Whether a value has been encrypted in place.
|
|
49
|
-
*
|
|
50
|
-
* `encrypted:` is the current envelope (`encrypted:v2:` with the versioned
|
|
51
|
-
* header); `enc:` is the short alias. The predicate was written out four
|
|
52
|
-
* separate times across this package before it lived here.
|
|
53
|
-
*/
|
|
54
|
-
export declare function isEncryptedValue(value: string | undefined | null): boolean;
|
|
55
|
-
/**
|
|
56
|
-
* Find values in a parsed env file that should have been encrypted.
|
|
57
|
-
*
|
|
58
|
-
* Returns key names, never values. Empty values, `${VAR}` expansions, dotenvx
|
|
59
|
-
* key metadata and anything already encrypted are skipped.
|
|
60
|
-
*/
|
|
61
|
-
export declare function plaintextSecrets(values: Record<string, string>, options?: PlaintextScanOptions): PlaintextFinding[];
|
|
62
|
-
/**
|
|
63
|
-
* Whether an env file is committed to git and therefore in scope.
|
|
64
|
-
*
|
|
65
|
-
* Enumerating with `git ls-files` rather than a hardcoded
|
|
66
|
-
* production/staging/ci list, because `.gitignore` un-ignores three names but
|
|
67
|
-
* `.env.development` is tracked as well - no ignore rule happens to match it.
|
|
68
|
-
* A hardcoded list would have skipped a file that is just as public.
|
|
69
|
-
*/
|
|
70
|
-
export declare function trackedEnvFiles(gitLsFilesOutput: string): string[];
|
|
71
64
|
/**
|
|
72
65
|
* Key names that should never hold readable text in a committed file.
|
|
73
66
|
*
|
|
@@ -85,3 +78,5 @@ export declare interface PlaintextScanOptions {
|
|
|
85
78
|
strict?: boolean
|
|
86
79
|
}
|
|
87
80
|
export type PlaintextReason = 'plaintext-secret' | 'plaintext-value';
|
|
81
|
+
// Re-exported so `@stacksjs/env` keeps the same public surface.
|
|
82
|
+
export { isEncryptedValue };
|
package/dist/plaintext.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import{isEncryptedValue}from"./encrypted-value";export{isEncryptedValue};export const SECRET_NAME_PATTERN=/(?:^|_)(?:PASSWORD|PASSWD|SECRET|TOKEN|APIKEY|API_KEY|CREDENTIAL|CREDENTIALS|PRIVATE|SALT|DSN|CERT|CIPHER|SIGNATURE|WEBHOOK)(?:_|$)|(?:^|_)(?:ACCESS|SECRET)_KEY(?:_|$)|_KEY$/;const CRYPTO_METADATA_PATTERN=/^DOTENV_(?:PUBLIC|PRIVATE)_KEY/,EXPANSION_PATTERN=/^\$\{[^}]+\}$/;export function plaintextSecrets(values,options={}){const{placeholders={},strict=!1}=options,findings=[];for(const[key,value]of Object.entries(values)){if(!value||value.trim()==="")continue;if(isEncryptedValue(value))continue;if(CRYPTO_METADATA_PATTERN.test(key))continue;if(EXPANSION_PATTERN.test(value.trim()))continue;const looksSecret=SECRET_NAME_PATTERN.test(key),isPlaceholder=Object.prototype.hasOwnProperty.call(placeholders,key)&&placeholders[key]===value;if(looksSecret&&!isPlaceholder){findings.push({key,reason:"plaintext-secret"});continue}if(strict)findings.push({key,reason:"plaintext-value"})}return findings}export function trackedEnvFiles(gitLsFilesOutput){return gitLsFilesOutput.split(`
|
|
2
2
|
`).map((line)=>line.trim()).filter(Boolean).filter((file)=>file!==".env.example").filter((file)=>/(?:^|\/)\.env(?:\.|$)/.test(file)).filter((file)=>!file.endsWith(".env.keys"))}
|
package/dist/plugin.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync}from"node:fs";import{readFileSync}from"node:fs";import{resolve}from"node:path";import{decryptValue,getPrivateKey}from"./crypto";import{parse}from"./parser";import{isEncryptedValue}from"./
|
|
1
|
+
import{existsSync}from"node:fs";import{readFileSync}from"node:fs";import{resolve}from"node:path";import{decryptValue,getPrivateKey}from"./crypto";import{parse}from"./parser";import{isEncryptedValue}from"./encrypted-value";import{plaintextEnv}from"./plaintext-env";function normalizeEnvName(value){if(!value||isEncryptedValue(value))return;const normalized=value.trim().toLowerCase();if(!/^[a-z0-9_-]+$/.test(normalized))return;if(normalized==="prod")return"production";if(normalized==="stage")return"staging";if(normalized==="dev")return"development";return normalized}let cachedKeyEnv=null,cachedKeyCwd=null,cachedPrivateKey,loadedEnvName;function keyFromKeysFile(envName,cwd,keysFile=".env.keys"){const keysPath=resolve(cwd,keysFile);if(!existsSync(keysPath))return;try{const{parsed}=parse(readFileSync(keysPath,"utf-8"));if(envName){const specific=parsed[`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`];if(specific)return specific}return parsed.DOTENV_PRIVATE_KEY}catch{return}}export function activeEnvName(options={}){return normalizeEnvName(options.env)||loadedEnvName||normalizeEnvName("development")||normalizeEnvName(process.env.DOTENV_ENV)||normalizeEnvName(process.env.APP_ENV)||"development"}export function resolvePrivateKey(options={}){const envName=activeEnvName(options),cwd=resolve(options.cwd||process.cwd());if(cachedKeyEnv===envName&&cachedKeyCwd===cwd)return cachedPrivateKey;let key=getPrivateKey(envName);if(!key)key=process.env.DOTENV_PRIVATE_KEY;if(!key)key=keyFromKeysFile(envName,cwd);cachedKeyEnv=envName;cachedKeyCwd=cwd;cachedPrivateKey=key;return key}export function resetPrivateKeyCache(){cachedKeyEnv=null;cachedKeyCwd=null;cachedPrivateKey=void 0;loadedEnvName=void 0}export function decryptEnvValue(value,options={}){if(!isEncryptedValue(value))return value;const key=resolvePrivateKey(options);if(!key)return;try{const normalized=value.startsWith("enc:")?`encrypted:${value.slice(4)}`:value;return decryptValue(normalized,key)}catch{return}}export function loadEnv(options={}){const{path:envPaths=[".env"],overload=!1,env:envName,privateKey:customPrivateKey,keysFile=".env.keys",quiet=!1,cwd=process.cwd()}=options,paths=Array.isArray(envPaths)?envPaths:[envPaths],errors=[];let loaded=0;const externalOverrides=new Set(Object.entries(plaintextEnv(process.env)).filter(([,value])=>value!==void 0).map(([key])=>key));let privateKey=customPrivateKey;if(!privateKey&&keysFile){const keysPath=resolve(cwd,keysFile);if(existsSync(keysPath))try{const keysContent=readFileSync(keysPath,"utf-8"),{parsed}=parse(keysContent);if(envName){const keyName=`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`;privateKey=parsed[keyName]||parsed.DOTENV_PRIVATE_KEY}else privateKey=parsed.DOTENV_PRIVATE_KEY}catch(error){errors.push(`Failed to load keys file: ${error instanceof Error?error.message:"Unknown error"}`)}}if(!privateKey)privateKey=getPrivateKey(envName||"")||getPrivateKey();for(const envPath of paths){const fullPath=resolve(cwd,envPath);if(!existsSync(fullPath)){if(!quiet)errors.push(`File not found: ${fullPath}`);continue}try{const content=readFileSync(fullPath,"utf-8"),{parsed,errors:parseErrors,skippedEncrypted}=parse(content,{privateKey,processEnv:process.env});errors.push(...parseErrors);if(skippedEncrypted.length>0){const usable=plaintextEnv(process.env,skippedEncrypted);for(const key of Object.keys(process.env))if(!Object.hasOwn(usable,key))delete process.env[key];const keyName=envName?`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`:"DOTENV_PRIVATE_KEY",reason=privateKey?"decryption failed":`${keyName} not set`,preview=skippedEncrypted.slice(0,5).join(", "),rest=skippedEncrypted.length>5?`, \u2026 +${skippedEncrypted.length-5} more`:"";console.warn(`[env] warning: skipped ${skippedEncrypted.length} encrypted value(s) in ${envPath} (${reason}; existing plaintext or defaults apply): ${preview}${rest}`)}let applied=0;for(const[key,value]of Object.entries(parsed)){if(key==="DOTENV_PUBLIC_KEY")continue;const existing=process.env[key],isStaleCiphertext=isEncryptedValue(existing)&&value!==existing;if(overload||!externalOverrides.has(key)||isStaleCiphertext){process.env[key]=value;loaded++;applied++}}if(!quiet&&!process.env.__ENV_LOADED__){console.error(`[env] loaded ${applied}/${Object.keys(parsed).length} variables from ${envPath}`);process.env.__ENV_LOADED__="1"}}catch(error){errors.push(`Failed to load ${fullPath}: ${error instanceof Error?error.message:"Unknown error"}`)}}scrubForeignCiphertext(privateKey);return{loaded,errors}}const reportedForeignCiphertext=new Set;function scrubForeignCiphertext(privateKey){const undecryptable=[];for(const[key,value]of Object.entries(process.env)){if(!isEncryptedValue(value))continue;let plaintext;if(privateKey)try{plaintext=decryptValue(value.startsWith("enc:")?`encrypted:${value.slice(4)}`:value,privateKey)}catch{}if(plaintext===void 0)undecryptable.push(key);else process.env[key]=plaintext}if(!undecryptable.length)return;const usable=plaintextEnv(process.env,undecryptable);for(const key of Object.keys(process.env))if(!Object.hasOwn(usable,key))delete process.env[key];const fresh=undecryptable.filter((key)=>!reportedForeignCiphertext.has(key));if(!fresh.length)return;for(const key of fresh)reportedForeignCiphertext.add(key);const preview=fresh.slice(0,5).join(", "),rest=fresh.length>5?`, \u2026 +${fresh.length-5} more`:"";console.warn(`[env] warning: unset ${fresh.length} encrypted value(s) no loaded env file could decrypt (Bun preloads .env.<NODE_ENV> on its own): ${preview}${rest}`)}export function envPlugin(options={}){return{name:"env-plugin",setup(build){loadEnv(options)}}}export function autoLoadEnv(options={}){const env=normalizeEnvName(options.env)||normalizeEnvName("development")||normalizeEnvName(process.env.DOTENV_ENV)||normalizeEnvName(process.env.APP_ENV)||"development",cwd=options.cwd||process.cwd();if(loadedEnvName!==env){loadedEnvName=env;cachedKeyEnv=null;cachedKeyCwd=null;cachedPrivateKey=void 0}const paths=[],addIfPresent=(file)=>{if(!paths.includes(file)&&existsSync(resolve(cwd,file)))paths.push(file)};addIfPresent(".env");addIfPresent(".env.local");const envFile=`.env.${env}`,envLocalFile=`.env.${env}.local`;addIfPresent(envFile);addIfPresent(envLocalFile);return loadEnv({...options,path:paths,env})}export default envPlugin;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/env",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.186",
|
|
6
6
|
"description": "Stacks env helper methods.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -56,10 +56,10 @@
|
|
|
56
56
|
"prepublishOnly": "bun run build"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@stacksjs/path": "0.75.
|
|
59
|
+
"@stacksjs/path": "0.75.186"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"better-dx": "^0.2.24",
|
|
63
|
-
"@stacksjs/validation": "0.75.
|
|
63
|
+
"@stacksjs/validation": "0.75.186"
|
|
64
64
|
}
|
|
65
65
|
}
|