@sous-io/sous 0.2.33 → 0.2.35
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/docs/markdown/repositories-variables.md +16 -0
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +8 -3
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/lib/build-service.ts +8 -4
- package/src/lib/markdown-compiler.ts +17 -2
- package/src/lib/repos/git-clone.ts +8 -2
- package/src/lib/repos/providers/git.ts +4 -0
- package/src/lib/repos/recipe-targets.ts +5 -0
- package/src/lib/repos/store/hash.ts +17 -0
- package/src/lib/vars/answers.ts +9 -1
- package/src/lib/vars/display.ts +1 -3
- package/src/lib/vars/index.ts +1 -0
- package/src/lib/vars/secrets.ts +131 -0
- package/src/templating/init-liquid-engine.ts +8 -0
- package/src/templating/lib/secret-scope.ts +41 -0
- package/src/templating/tags/exportScalarVarsJs.ts +5 -2
- package/src/templating/tags/showVars.ts +8 -2
|
@@ -341,6 +341,22 @@ name in `_vars`, for instance `taskFileDir: "${sousDir}/../${taskFileRoot}"`.
|
|
|
341
341
|
|
|
342
342
|
The `_env` block is still the way to reach any environment variable no recipe asks about.
|
|
343
343
|
|
|
344
|
+
### Secrets stay out of scope dumps
|
|
345
|
+
|
|
346
|
+
A secret renders only where a template names it, as in `{{ apiToken }}`. The two tags that print the whole
|
|
347
|
+
scope leave secrets out: `{% showVars %}` prints `(hidden)` in place of the value, and
|
|
348
|
+
`{% exportScalarVarsJs %}` drops the variable, because runtime code importing the module would read a mask
|
|
349
|
+
as a real value. Both treat a variable as a secret when either holds:
|
|
350
|
+
|
|
351
|
+
- A recipe declares it `secret: true`.
|
|
352
|
+
- It very probably is one, by a thin check that needs no declaration: a name whose last words say so
|
|
353
|
+
(`githubToken`, `OPENAI_API_KEY`, `dbPassword`, `clientSecret`) holding more than a number or a switch, or
|
|
354
|
+
a value in a widely used token format (a GitHub or GitLab token, an AWS access key id, a private key block,
|
|
355
|
+
a JSON Web Token, a URL carrying a password).
|
|
356
|
+
|
|
357
|
+
So `serviceApiKey: "abc123"` in `_vars` is hidden from a dump, while `maxTokens: "4096"` and
|
|
358
|
+
`requireToken: "true"` still show.
|
|
359
|
+
|
|
344
360
|
## Where to go next
|
|
345
361
|
|
|
346
362
|
- [Consuming recipes](repositories-consuming.md): subscribing, building, and what lands where
|
package/package.json
CHANGED
|
@@ -205,7 +205,10 @@ Sous plugin; a Sous plugin comes only from sous's built-ins or an npm-style modu
|
|
|
205
205
|
|
|
206
206
|
**`showVars`**: dumps all variables currently in scope as a fenced JSON block.
|
|
207
207
|
Useful during development to see exactly what variables are available at a given point
|
|
208
|
-
in a template. Remove before finalizing.
|
|
208
|
+
in a template. Remove before finalizing. A secret prints as `(hidden)`: a variable a
|
|
209
|
+
recipe declares `secret: true`, or one that very probably is a secret by its name
|
|
210
|
+
(`githubToken`, `OPENAI_API_KEY`) or its format (a GitHub token, a private key block). A
|
|
211
|
+
template that names a secret explicitly, as in `{% raw %}{{ apiToken }}{% endraw %}`, still renders it.
|
|
209
212
|
|
|
210
213
|
**`getFiles`**: globs files under a root directory and assigns the resulting array to a
|
|
211
214
|
template variable. It renders nothing; present the results yourself with a `for` loop.
|
|
@@ -238,7 +241,8 @@ render paths relative to the root instead of bare file names:
|
|
|
238
241
|
|
|
239
242
|
**`exportScalarVarsJs`**: emits every in-scope scalar variable (string, finite number,
|
|
240
243
|
boolean) as an ES module default export, keys sorted. Objects, arrays, `null` and
|
|
241
|
-
non-finite numbers are skipped
|
|
244
|
+
non-finite numbers are skipped, and so is every secret (the same ones `showVars` hides),
|
|
245
|
+
since runtime code would read a mask as a real value. Use it to compile a settings module that runtime code
|
|
242
246
|
imports, rather than re-deriving project configuration:
|
|
243
247
|
|
|
244
248
|
{% raw %}
|
|
@@ -294,7 +298,8 @@ project's variables change.
|
|
|
294
298
|
|
|
295
299
|
## Available Variables
|
|
296
300
|
|
|
297
|
-
The following variables are in scope at compile time in this project
|
|
301
|
+
The following variables are in scope at compile time in this project (secrets show as
|
|
302
|
+
`(hidden)`):
|
|
298
303
|
|
|
299
304
|
{% showVars %}
|
|
300
305
|
|
package/src/lib/build-service.ts
CHANGED
|
@@ -118,13 +118,15 @@ export function resolveRecipeTargets(
|
|
|
118
118
|
|
|
119
119
|
const missing = unansweredWarning(resolved, rootScope);
|
|
120
120
|
if (missing !== undefined) recipes.warnings.push(missing);
|
|
121
|
-
return recipes;
|
|
121
|
+
return { ...recipes, secretVariables: [...resolved.secrets] };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
125
|
* Adds the recipe targets to a project's own compilation config. A project with
|
|
126
126
|
* no compilation block of its own still compiles its recipes, so the config is
|
|
127
|
-
* created when there is none and there is something to compile.
|
|
127
|
+
* created when there is none and there is something to compile. The names the
|
|
128
|
+
* recipes declare secret go onto the config either way, because a project's own
|
|
129
|
+
* template may dump the scope too.
|
|
128
130
|
*
|
|
129
131
|
* @param config - The project's own compilation config, or null when it has none.
|
|
130
132
|
* @param recipes - The targets the subscribed recipes contribute.
|
|
@@ -137,15 +139,17 @@ export function withRecipeTargets(
|
|
|
137
139
|
settings: Settings,
|
|
138
140
|
rootScope: Record<string, string>
|
|
139
141
|
): CompilationConfig | null {
|
|
140
|
-
|
|
142
|
+
const secrets = recipes.secretVariables?.length ? { secretVariables: recipes.secretVariables } : {};
|
|
143
|
+
if (recipes.targets.length === 0) return config === null ? null : { ...config, ...secrets };
|
|
141
144
|
if (config === null) {
|
|
142
145
|
return {
|
|
143
146
|
targets: recipes.targets,
|
|
144
147
|
aliases: resolveAliases(settings, rootScope),
|
|
145
148
|
includeScope: rootScope,
|
|
149
|
+
...secrets,
|
|
146
150
|
};
|
|
147
151
|
}
|
|
148
|
-
return { ...config, targets: [...config.targets, ...recipes.targets] };
|
|
152
|
+
return { ...config, targets: [...config.targets, ...recipes.targets], ...secrets };
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
/**
|
|
@@ -68,6 +68,12 @@ export type CompilationConfig = {
|
|
|
68
68
|
views?: ViewMap;
|
|
69
69
|
/** Variable scope for `${var}` substitution in `@include` paths. */
|
|
70
70
|
includeScope?: Record<string, string>;
|
|
71
|
+
/**
|
|
72
|
+
* Every variable name a recipe declares `secret: true`. The tags that dump a
|
|
73
|
+
* whole scope hide these (and anything that very probably is a secret); a
|
|
74
|
+
* template that names one explicitly still renders it.
|
|
75
|
+
*/
|
|
76
|
+
secretVariables?: string[];
|
|
71
77
|
};
|
|
72
78
|
|
|
73
79
|
export type CompilationServiceOptions = {
|
|
@@ -209,6 +215,8 @@ export class CompilationService {
|
|
|
209
215
|
private numberFormatter: Intl.NumberFormat;
|
|
210
216
|
private aliases: AliasMap;
|
|
211
217
|
private includeScope: Record<string, string>;
|
|
218
|
+
/** Every variable name a recipe declares secret, for the scope-dumping tags. */
|
|
219
|
+
private secretVariables: string[];
|
|
212
220
|
private namespaceResolver?: NamespaceResolver;
|
|
213
221
|
/**
|
|
214
222
|
* `.tpl.` outputs that were written without a variable scope, so LiquidJS never
|
|
@@ -232,6 +240,7 @@ export class CompilationService {
|
|
|
232
240
|
this.numberFormatter = new Intl.NumberFormat("en-US");
|
|
233
241
|
this.aliases = {};
|
|
234
242
|
this.includeScope = {};
|
|
243
|
+
this.secretVariables = [];
|
|
235
244
|
this.namespaceResolver = options.namespaceResolver;
|
|
236
245
|
this.unrenderedTemplates = [];
|
|
237
246
|
}
|
|
@@ -535,6 +544,7 @@ export class CompilationService {
|
|
|
535
544
|
scope: { ...this.includeScope, ...vars },
|
|
536
545
|
namespaceResolver: this.namespaceResolver,
|
|
537
546
|
fromFile,
|
|
547
|
+
secretVariables: this.secretVariables,
|
|
538
548
|
});
|
|
539
549
|
try {
|
|
540
550
|
return await engine.parseAndRender(content, vars);
|
|
@@ -700,9 +710,13 @@ ${taskFileContents}
|
|
|
700
710
|
// A rendered output depends on its variables as much as on its source: a
|
|
701
711
|
// changed answer or `_vars` value with the same template must re-render,
|
|
702
712
|
// so the variable scope is part of a `.tpl.` output's source hash. A
|
|
703
|
-
// verbatim copy hashes its content alone.
|
|
713
|
+
// verbatim copy hashes its content alone. The secret names are part of it
|
|
714
|
+
// too, because they decide what a scope-dumping tag hides.
|
|
704
715
|
const srcHash = rendersAnything && output.vars
|
|
705
|
-
? hashContent(
|
|
716
|
+
? hashContent(
|
|
717
|
+
`${contentHash}\n${stableVarsFingerprint(output.vars)}\n` +
|
|
718
|
+
`secrets:${JSON.stringify([...this.secretVariables].sort())}`
|
|
719
|
+
)
|
|
706
720
|
: contentHash;
|
|
707
721
|
|
|
708
722
|
// Skip if content is unchanged and file already exists (unless --rebuild)
|
|
@@ -859,6 +873,7 @@ ${taskFileContents}
|
|
|
859
873
|
this.views = config.views ?? {};
|
|
860
874
|
this.includedFilesSeen = new Set();
|
|
861
875
|
this.includeScope = config.includeScope ?? {};
|
|
876
|
+
this.secretVariables = config.secretVariables ?? [];
|
|
862
877
|
|
|
863
878
|
this.initializeEncoder();
|
|
864
879
|
|
|
@@ -16,6 +16,7 @@ import fs from "node:fs";
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { ConfigError } from "../errors.js";
|
|
19
|
+
import { LF_CHECKOUT_OPTIONS } from "./store/hash.js";
|
|
19
20
|
|
|
20
21
|
/** What one git invocation produced. */
|
|
21
22
|
export type GitResult = {
|
|
@@ -211,15 +212,20 @@ export function cloneRepo(
|
|
|
211
212
|
const depth = options.depth ?? 1;
|
|
212
213
|
const runner = options.runner ?? runGit;
|
|
213
214
|
|
|
215
|
+
// A linked checkout is hashed when its recipes are released or compiled, so
|
|
216
|
+
// it keeps the committed bytes whatever this machine's line-ending settings are.
|
|
214
217
|
if (depth > 0) {
|
|
215
|
-
const shallow = runner(
|
|
218
|
+
const shallow = runner(
|
|
219
|
+
["clone", ...LF_CHECKOUT_OPTIONS, "--depth", String(depth), "--", url, destDir],
|
|
220
|
+
{}
|
|
221
|
+
);
|
|
216
222
|
if (shallow.status === 0) return { depth, fellBackToFullClone: false };
|
|
217
223
|
// git leaves the destination behind on some failures; clear it so the retry
|
|
218
224
|
// is not refused by its own leftovers.
|
|
219
225
|
fs.rmSync(destDir, { recursive: true, force: true });
|
|
220
226
|
}
|
|
221
227
|
|
|
222
|
-
runGitOrThrow(["clone", "--", url, destDir], {
|
|
228
|
+
runGitOrThrow(["clone", ...LF_CHECKOUT_OPTIONS, "--", url, destDir], {
|
|
223
229
|
runner: options.runner,
|
|
224
230
|
what: `Cloning ${url}`,
|
|
225
231
|
});
|
|
@@ -14,6 +14,7 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import fs from "node:fs/promises";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { ConfigError } from "../../errors.js";
|
|
17
|
+
import { LF_CHECKOUT_OPTIONS } from "../store/hash.js";
|
|
17
18
|
|
|
18
19
|
/** What a finished subprocess reports back. */
|
|
19
20
|
export type CommandResult = {
|
|
@@ -174,6 +175,9 @@ export async function fetchSubtree(options: {
|
|
|
174
175
|
"1",
|
|
175
176
|
"--filter=blob:none",
|
|
176
177
|
"--sparse",
|
|
178
|
+
// The fetched subtree is hashed against its pin, so its bytes must be the
|
|
179
|
+
// committed ones whatever this machine's line-ending settings are.
|
|
180
|
+
...LF_CHECKOUT_OPTIONS,
|
|
177
181
|
"--branch",
|
|
178
182
|
tag,
|
|
179
183
|
// Everything after this is a path or a URL, never an option, whatever it
|
|
@@ -78,6 +78,11 @@ export type RecipeTargets = {
|
|
|
78
78
|
watchDirs: string[];
|
|
79
79
|
/** Complete, plain-language sentences a build should print before it compiles. */
|
|
80
80
|
warnings: string[];
|
|
81
|
+
/**
|
|
82
|
+
* Every variable name a recipe declares `secret: true`, which the scope-dumping
|
|
83
|
+
* template tags hide. Set by the build from the resolved answers.
|
|
84
|
+
*/
|
|
85
|
+
secretVariables?: string[];
|
|
81
86
|
};
|
|
82
87
|
|
|
83
88
|
/**
|
|
@@ -35,6 +35,23 @@ import fs from "node:fs/promises";
|
|
|
35
35
|
import path from "node:path";
|
|
36
36
|
import { STORE_ENTRY_FILENAME } from "../formats/common.js";
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* `git clone` options that check files out exactly as they were committed.
|
|
40
|
+
*
|
|
41
|
+
* A content hash is over raw bytes, and Git for Windows installs with
|
|
42
|
+
* `core.autocrlf=true` by default, which rewrites every text file to CRLF on
|
|
43
|
+
* checkout. A recipe published with LF then hashed differently on such a
|
|
44
|
+
* machine and failed its own pin. `--config` also writes these into the
|
|
45
|
+
* clone's own `.git/config`, so later checkouts in a linked working copy keep
|
|
46
|
+
* the same bytes.
|
|
47
|
+
*/
|
|
48
|
+
export const LF_CHECKOUT_OPTIONS = [
|
|
49
|
+
"--config",
|
|
50
|
+
"core.autocrlf=false",
|
|
51
|
+
"--config",
|
|
52
|
+
"core.eol=lf",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
38
55
|
/** Directory name never included in a content hash. */
|
|
39
56
|
const GIT_DIR_NAME = ".git";
|
|
40
57
|
|
package/src/lib/vars/answers.ts
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
* is something to tell the user about, not a reason to refuse to build the
|
|
24
24
|
* rest of the project.
|
|
25
25
|
*
|
|
26
|
+
* Alongside them comes `secrets`: every name some definition declares
|
|
27
|
+
* `secret: true`. The tags that dump a whole scope (`{% showVars %}`,
|
|
28
|
+
* `{% exportScalarVarsJs %}`) read it so a secret answer never lands in
|
|
29
|
+
* compiled output unless a template names it explicitly.
|
|
30
|
+
*
|
|
26
31
|
* A definition's `default` counts as an answer of last resort: the
|
|
27
32
|
* description a publisher writes promises what the default does, and a
|
|
28
33
|
* template rendering an empty string instead would break that promise.
|
|
@@ -49,6 +54,8 @@ export interface RecipeAnswers {
|
|
|
49
54
|
byRecipe: Map<string, VarScope>;
|
|
50
55
|
/** Every required definition that nothing answered and that has no default. */
|
|
51
56
|
unanswered: DefinedVariable[];
|
|
57
|
+
/** Every variable name some definition declares `secret: true`, answered or not. */
|
|
58
|
+
secrets: Set<string>;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/** How the answers are resolved. */
|
|
@@ -76,7 +83,7 @@ export interface RecipeAnswerOptions {
|
|
|
76
83
|
|
|
77
84
|
/** The empty result, for a project that locks nothing. */
|
|
78
85
|
export function noRecipeAnswers(): RecipeAnswers {
|
|
79
|
-
return { merged: {}, byRecipe: new Map(), unanswered: [] };
|
|
86
|
+
return { merged: {}, byRecipe: new Map(), unanswered: [], secrets: new Set() };
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/**
|
|
@@ -103,6 +110,7 @@ export function resolveRecipeAnswers(options: RecipeAnswerOptions): RecipeAnswer
|
|
|
103
110
|
for (const defined of definitions) {
|
|
104
111
|
const value = answerFor(defined, context);
|
|
105
112
|
const name = defined.definition.name;
|
|
113
|
+
if (defined.definition.secret) result.secrets.add(name);
|
|
106
114
|
|
|
107
115
|
if (value === undefined) {
|
|
108
116
|
if (defined.definition.required) result.unanswered.push(defined);
|
package/src/lib/vars/display.ts
CHANGED
|
@@ -19,9 +19,7 @@ import {
|
|
|
19
19
|
type DefiningRecipe,
|
|
20
20
|
} from "./definition-source.js";
|
|
21
21
|
import { constraintBullets } from "./validate.js";
|
|
22
|
-
|
|
23
|
-
/** What the value column shows for a secret whose answer is known. */
|
|
24
|
-
export const HIDDEN_VALUE = "(hidden)";
|
|
22
|
+
import { HIDDEN_VALUE } from "./secrets.js";
|
|
25
23
|
|
|
26
24
|
/** What the value column shows for a variable nothing has answered. */
|
|
27
25
|
export const UNANSWERED_VALUE = "(unanswered)";
|
package/src/lib/vars/index.ts
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which variables hold a secret.
|
|
3
|
+
*
|
|
4
|
+
* A recipe marks a variable `secret: true`, and that declaration is the
|
|
5
|
+
* authority. Everything else in a template's scope (an `_env` mapping, a
|
|
6
|
+
* project `_vars` entry, an answer to a definition that forgot the flag) is
|
|
7
|
+
* checked by a deliberately thin heuristic: a name whose last words say it is
|
|
8
|
+
* a secret (`githubToken`, `OPENAI_API_KEY`, `dbPassword`), or a value in one
|
|
9
|
+
* of the widely used token formats (a GitHub or GitLab token, an AWS access
|
|
10
|
+
* key, a private key block, a URL carrying a password). The heuristic only
|
|
11
|
+
* catches what is very probably a secret; it is a safety net, not a scanner.
|
|
12
|
+
*
|
|
13
|
+
* This module imports nothing, so the template tags can use it without
|
|
14
|
+
* pulling the variables layer into the template engine.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** What a hidden value shows in place of the secret. */
|
|
18
|
+
export const HIDDEN_VALUE = "(hidden)";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Name endings that mark a secret, as lowercase words. A name matches when its
|
|
22
|
+
* last words are one of these, so `apiToken` and `GITHUB_TOKEN` match while
|
|
23
|
+
* `tokenLimit` does not.
|
|
24
|
+
*/
|
|
25
|
+
const SECRET_NAME_ENDINGS: readonly (readonly string[])[] = [
|
|
26
|
+
["token"],
|
|
27
|
+
["secret"],
|
|
28
|
+
["secrets"],
|
|
29
|
+
["password"],
|
|
30
|
+
["passwd"],
|
|
31
|
+
["passphrase"],
|
|
32
|
+
["credential"],
|
|
33
|
+
["credentials"],
|
|
34
|
+
["apikey"],
|
|
35
|
+
["api", "key"],
|
|
36
|
+
["private", "key"],
|
|
37
|
+
["access", "key"],
|
|
38
|
+
["secret", "key"],
|
|
39
|
+
["signing", "key"],
|
|
40
|
+
["encryption", "key"],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Widely used secret formats, each specific enough that a match is very probably a secret. */
|
|
44
|
+
const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
|
|
45
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // a PEM private key
|
|
46
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}/, // GitHub token
|
|
47
|
+
/\bgithub_pat_[A-Za-z0-9_]{22,}/, // GitHub fine-grained token
|
|
48
|
+
/\bglpat-[A-Za-z0-9_-]{20,}/, // GitLab personal access token
|
|
49
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, // AWS access key id
|
|
50
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}/, // Slack token
|
|
51
|
+
/\b[rs]k_live_[A-Za-z0-9]{16,}/, // Stripe live key
|
|
52
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}/, // Anthropic API key
|
|
53
|
+
/\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/, // OpenAI API key
|
|
54
|
+
/\bAIza[0-9A-Za-z_-]{35}/, // Google API key
|
|
55
|
+
/\bnpm_[A-Za-z0-9]{36}\b/, // npm token
|
|
56
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JSON Web Token
|
|
57
|
+
/^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i, // a URL carrying a password
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Values a secret-sounding name may hold without being a secret: nothing, a number, a switch. */
|
|
61
|
+
const PLAIN_VALUE = /^(?:|-?\d+(?:\.\d+)?|true|false|yes|no|on|off)$/i;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The words in a variable name, lowercased: split at `_`, `-`, `.`, spaces and
|
|
65
|
+
* camelCase boundaries, so `OPENAI_API_KEY`, `openaiApiKey` and `openai-api-key`
|
|
66
|
+
* all read as `openai api key`.
|
|
67
|
+
*
|
|
68
|
+
* @param name - The variable name.
|
|
69
|
+
*/
|
|
70
|
+
function nameWords(name: string): string[] {
|
|
71
|
+
return name
|
|
72
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
73
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
74
|
+
.split(/[^A-Za-z0-9]+/)
|
|
75
|
+
.filter((word) => word.length > 0)
|
|
76
|
+
.map((word) => word.toLowerCase());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a variable name says it holds a secret.
|
|
81
|
+
*
|
|
82
|
+
* @param name - The variable name.
|
|
83
|
+
*/
|
|
84
|
+
export function hasSecretName(name: string): boolean {
|
|
85
|
+
const words = nameWords(name);
|
|
86
|
+
return SECRET_NAME_ENDINGS.some(
|
|
87
|
+
(ending) =>
|
|
88
|
+
words.length >= ending.length &&
|
|
89
|
+
ending.every((word, i) => words[words.length - ending.length + i] === word)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a value is in one of the widely used secret formats.
|
|
95
|
+
*
|
|
96
|
+
* @param value - The value.
|
|
97
|
+
*/
|
|
98
|
+
export function hasSecretValue(value: string): boolean {
|
|
99
|
+
return SECRET_VALUE_PATTERNS.some((pattern) => pattern.test(value));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a variable very probably holds a secret: its name says so (and its
|
|
104
|
+
* value is more than a number or a switch), or its value is in a known secret
|
|
105
|
+
* format.
|
|
106
|
+
*
|
|
107
|
+
* @param name - The variable name.
|
|
108
|
+
* @param value - Its value.
|
|
109
|
+
*/
|
|
110
|
+
export function looksLikeSecret(name: string, value: string): boolean {
|
|
111
|
+
if (hasSecretValue(value)) return true;
|
|
112
|
+
return hasSecretName(name) && !PLAIN_VALUE.test(value.trim());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether a variable is to be treated as a secret: a recipe declared it one,
|
|
117
|
+
* or the heuristic says it very probably is.
|
|
118
|
+
*
|
|
119
|
+
* @param name - The variable name.
|
|
120
|
+
* @param value - Its value.
|
|
121
|
+
* @param declared - The names recipes declared `secret: true`.
|
|
122
|
+
*/
|
|
123
|
+
export function isSecretVariable(
|
|
124
|
+
name: string,
|
|
125
|
+
value: unknown,
|
|
126
|
+
declared: ReadonlySet<string> = new Set()
|
|
127
|
+
): boolean {
|
|
128
|
+
if (typeof value !== "string") return false;
|
|
129
|
+
if (declared.has(name)) return value.length > 0;
|
|
130
|
+
return looksLikeSecret(name, value);
|
|
131
|
+
}
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { Liquid, type FS } from "liquidjs";
|
|
4
4
|
import filterRegistrars from "./filters/index.js";
|
|
5
5
|
import tagRegistrars from "./tags/index.js";
|
|
6
|
+
import { setSecretNames } from "./lib/secret-scope.js";
|
|
6
7
|
import { resolveIncludeFiles, type AliasMap, type ViewMap } from "../lib/include-resolver.js";
|
|
7
8
|
import {
|
|
8
9
|
formatNamespaceProblem,
|
|
@@ -25,6 +26,12 @@ export type EngineAliasOptions = {
|
|
|
25
26
|
* resolves them against, which is enough to locate the owning recipe.
|
|
26
27
|
*/
|
|
27
28
|
fromFile?: string;
|
|
29
|
+
/**
|
|
30
|
+
* The variable names a recipe declared `secret: true`. `{% showVars %}` and
|
|
31
|
+
* `{% exportScalarVarsJs %}` hide them, along with anything that very probably
|
|
32
|
+
* is a secret.
|
|
33
|
+
*/
|
|
34
|
+
secretVariables?: Iterable<string>;
|
|
28
35
|
};
|
|
29
36
|
|
|
30
37
|
/**
|
|
@@ -127,5 +134,6 @@ export function createLiquidEngine(roots: string[], aliasOpts: EngineAliasOption
|
|
|
127
134
|
register(engine);
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
setSecretNames(engine, aliasOpts.secretVariables ?? []);
|
|
130
138
|
return engine;
|
|
131
139
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Liquid } from "liquidjs";
|
|
2
|
+
import { HIDDEN_VALUE, isSecretVariable } from "../../lib/vars/secrets.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The variable names each engine was told are secret. Kept beside the engine
|
|
6
|
+
* rather than in its scope, so the list itself never shows up in a dump.
|
|
7
|
+
*/
|
|
8
|
+
const secretNamesByEngine = new WeakMap<Liquid, ReadonlySet<string>>();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Records the variable names a recipe declared `secret: true` for one engine.
|
|
12
|
+
*
|
|
13
|
+
* @param engine - The engine the tags render with.
|
|
14
|
+
* @param names - The declared names.
|
|
15
|
+
*/
|
|
16
|
+
export function setSecretNames(engine: Liquid, names: Iterable<string>): void {
|
|
17
|
+
secretNamesByEngine.set(engine, new Set(names));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The declared secret names for an engine; empty when none were recorded.
|
|
22
|
+
*
|
|
23
|
+
* @param engine - The engine the tags render with.
|
|
24
|
+
*/
|
|
25
|
+
export function secretNamesFor(engine: Liquid): ReadonlySet<string> {
|
|
26
|
+
return secretNamesByEngine.get(engine) ?? new Set();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a scope entry is a secret a whole-scope dump must not print: a
|
|
31
|
+
* declared secret, or one the heuristic in `lib/vars/secrets.ts` recognizes.
|
|
32
|
+
*
|
|
33
|
+
* @param engine - The engine the tag renders with.
|
|
34
|
+
* @param name - The variable name.
|
|
35
|
+
* @param value - Its value.
|
|
36
|
+
*/
|
|
37
|
+
export function isSecretEntry(engine: Liquid, name: string, value: unknown): boolean {
|
|
38
|
+
return isSecretVariable(name, value, secretNamesFor(engine));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { HIDDEN_VALUE };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Liquid } from "liquidjs";
|
|
2
2
|
import type { Context } from "liquidjs/dist/context/context.js";
|
|
3
3
|
import { sortObjectKeys } from "../../utils/formatting.js";
|
|
4
|
+
import { isSecretEntry } from "../lib/secret-scope.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* A "scalar" for export purposes: the value types that can be safely embedded
|
|
@@ -20,7 +21,9 @@ function isScalar(value: unknown): value is string | number | boolean {
|
|
|
20
21
|
* Iterates every variable currently in the LiquidJS scope, keeps only scalars
|
|
21
22
|
* (strings, finite numbers, booleans), sorts them by key, and emits a valid
|
|
22
23
|
* `export default { ... };` block. Objects, arrays, functions, null, undefined,
|
|
23
|
-
* and non-finite numbers are skipped
|
|
24
|
+
* and non-finite numbers are skipped, and so is every secret (one a recipe
|
|
25
|
+
* declared, or one that very probably is): runtime code would read a masked
|
|
26
|
+
* value as a real one, so a secret is left out rather than hidden.
|
|
24
27
|
*
|
|
25
28
|
* Intended for compiling a `settings.tpl.mjs` that downstream runtime code
|
|
26
29
|
* (e.g. browser automation scripts) imports for project configuration.
|
|
@@ -32,7 +35,7 @@ export function registerExportScalarVarsJsTag(engine: Liquid): void {
|
|
|
32
35
|
const scalars: Record<string, string | number | boolean> = {};
|
|
33
36
|
|
|
34
37
|
for (const [key, value] of Object.entries(scope)) {
|
|
35
|
-
if (isScalar(value)) scalars[key] = value;
|
|
38
|
+
if (isScalar(value) && !isSecretEntry(engine, key, value)) scalars[key] = value;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
const sorted = sortObjectKeys(scalars);
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import type { Liquid } from "liquidjs";
|
|
2
2
|
import type { Context } from "liquidjs/dist/context/context.js";
|
|
3
3
|
import { sortObjectKeys } from "../../utils/formatting.js";
|
|
4
|
+
import { HIDDEN_VALUE, isSecretEntry } from "../lib/secret-scope.js";
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Dumps all variables currently in scope as a fenced JSON block. A secret (one a
|
|
8
|
+
* recipe declared, or one that very probably is) prints as `(hidden)`, so the
|
|
9
|
+
* dump never carries it into compiled output.
|
|
10
|
+
*/
|
|
6
11
|
export function registerShowVarsTag(engine: Liquid): void {
|
|
7
12
|
engine.registerTag("showVars", {
|
|
8
13
|
render(ctx: Context) {
|
|
9
14
|
const seen = new WeakSet();
|
|
10
15
|
const scope = sortObjectKeys(ctx.getAll() as Record<string, unknown>);
|
|
11
|
-
const json = JSON.stringify(scope, (
|
|
16
|
+
const json = JSON.stringify(scope, (key, value) => {
|
|
12
17
|
if (typeof value === "object" && value !== null) {
|
|
13
18
|
if (seen.has(value)) return "[Circular]";
|
|
14
19
|
seen.add(value);
|
|
15
20
|
}
|
|
21
|
+
if (key !== "" && isSecretEntry(engine, key, value)) return HIDDEN_VALUE;
|
|
16
22
|
return value;
|
|
17
23
|
}, 2);
|
|
18
24
|
|