actions-warden 0.2.0 → 0.3.0
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/AGENTS.md +189 -0
- package/CONTRIBUTING.md +109 -0
- package/README.md +272 -224
- package/RELEASING.md +338 -0
- package/SECURITY.md +25 -3
- package/docs/AI-AGENTS.md +458 -0
- package/docs/CLI.md +421 -0
- package/docs/CONFIGURATION.md +340 -0
- package/docs/DEVELOPMENT.md +350 -0
- package/docs/GITHUB-ACTION.md +281 -0
- package/docs/JAVASCRIPT-API.md +355 -0
- package/docs/OUTPUTS.md +409 -0
- package/docs/README.md +27 -0
- package/examples/org-scan.yml +42 -0
- package/examples/upgrade-pr.yml +57 -0
- package/llms.txt +38 -0
- package/package.json +32 -10
- package/skills/actions-warden/SKILL.md +140 -38
- package/src/action.js +317 -0
- package/src/cli.js +267 -22
- package/src/commands/audit.js +189 -36
- package/src/commands/org-scan.js +549 -0
- package/src/commands/pin.js +59 -56
- package/src/commands/report.js +122 -10
- package/src/commands/upgrade.js +102 -62
- package/src/commands/verify.js +193 -0
- package/src/index.js +21 -4
- package/src/lib/action-status.js +27 -0
- package/src/lib/agent-mode.js +175 -0
- package/src/lib/annotations.js +250 -0
- package/src/lib/baseline.js +103 -0
- package/src/lib/cache.js +47 -10
- package/src/lib/concurrency.js +27 -0
- package/src/lib/config.js +185 -0
- package/src/lib/execution.js +71 -0
- package/src/lib/formatter.js +127 -8
- package/src/lib/github-org.js +374 -0
- package/src/lib/identity.js +62 -0
- package/src/lib/ignore.js +7 -6
- package/src/lib/org-checkpoint.js +378 -0
- package/src/lib/org-progress.js +60 -0
- package/src/lib/parser.js +326 -52
- package/src/lib/patcher.js +199 -0
- package/src/lib/paths.js +35 -12
- package/src/lib/redact.js +65 -4
- package/src/lib/resolver.js +225 -43
- package/src/lib/targets.js +28 -0
- package/src/lib/triggers.js +12 -0
- package/src/lib/writer.js +45 -8
- package/src/rules/excessive-permissions.js +24 -33
- package/src/rules/index.js +19 -1
- package/src/rules/pull-request-target-checkout.js +149 -18
- package/src/rules/reusable-workflow-secrets.js +32 -0
- package/src/rules/script-injection.js +77 -12
- package/src/rules/secrets-in-env.js +101 -18
- package/src/rules/unpinned-action.js +3 -2
- package/src/rules/unpinned-container-image.js +39 -0
- package/src/rules/unpinned-docker-action.js +30 -0
- package/src/rules/untrusted-self-hosted-runner.js +109 -0
- package/src/rules/workflow-run-artifact-execution.js +122 -0
- package/src/rules/workflow-structure.js +396 -0
- package/src/version.js +3 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply non-overlapping source patches from right to left.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} source
|
|
5
|
+
* @param {Array<{start: number, end: number, text: string, expected?: string}>} patches
|
|
6
|
+
*/
|
|
7
|
+
export function applyPatches(source, patches) {
|
|
8
|
+
const ordered = [...patches].sort((a, b) => b.start - a.start || b.end - a.end);
|
|
9
|
+
let previousStart = source.length + 1;
|
|
10
|
+
let output = source;
|
|
11
|
+
|
|
12
|
+
for (const patch of ordered) {
|
|
13
|
+
if (
|
|
14
|
+
!Number.isInteger(patch.start)
|
|
15
|
+
|| !Number.isInteger(patch.end)
|
|
16
|
+
|| patch.start < 0
|
|
17
|
+
|| patch.end < patch.start
|
|
18
|
+
|| patch.end > source.length
|
|
19
|
+
) {
|
|
20
|
+
throw new Error('invalid source patch range');
|
|
21
|
+
}
|
|
22
|
+
if (patch.end > previousStart) {
|
|
23
|
+
throw new Error('overlapping source patches');
|
|
24
|
+
}
|
|
25
|
+
if (
|
|
26
|
+
patch.expected !== undefined
|
|
27
|
+
&& source.slice(patch.start, patch.end) !== patch.expected
|
|
28
|
+
) {
|
|
29
|
+
throw new Error('workflow changed while planning; refusing to apply stale patch');
|
|
30
|
+
}
|
|
31
|
+
output = output.slice(0, patch.start) + patch.text + output.slice(patch.end);
|
|
32
|
+
previousStart = patch.start;
|
|
33
|
+
}
|
|
34
|
+
return output;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Locate a parsed action reference when callers constructed it without ranges.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} source
|
|
41
|
+
* @param {{raw: string, line: number, start?: number, end?: number, lineStart?: number, lineEnd?: number}} ref
|
|
42
|
+
*/
|
|
43
|
+
export function locateActionRef(source, ref) {
|
|
44
|
+
if (
|
|
45
|
+
Number.isInteger(ref.start)
|
|
46
|
+
&& Number.isInteger(ref.end)
|
|
47
|
+
&& ref.end > ref.start
|
|
48
|
+
&& (
|
|
49
|
+
source.slice(ref.start, ref.end).includes(ref.raw)
|
|
50
|
+
|| ref.alias === true
|
|
51
|
+
)
|
|
52
|
+
) {
|
|
53
|
+
const lineStart = (
|
|
54
|
+
Number.isInteger(ref.lineStart)
|
|
55
|
+
&& ref.lineStart <= ref.start
|
|
56
|
+
&& !source.slice(ref.lineStart, ref.start).includes('\n')
|
|
57
|
+
)
|
|
58
|
+
? ref.lineStart
|
|
59
|
+
: source.lastIndexOf('\n', Math.max(ref.start - 1, 0)) + 1;
|
|
60
|
+
const newline = source.indexOf('\n', ref.end);
|
|
61
|
+
const computedLineEnd = newline === -1 ? source.length : newline;
|
|
62
|
+
const lineEnd = Number.isInteger(ref.lineEnd) && ref.lineEnd >= ref.end
|
|
63
|
+
? ref.lineEnd
|
|
64
|
+
: computedLineEnd;
|
|
65
|
+
return {
|
|
66
|
+
...ref,
|
|
67
|
+
lineStart,
|
|
68
|
+
lineEnd,
|
|
69
|
+
column: Number.isInteger(ref.column) && ref.column > 0
|
|
70
|
+
? ref.column
|
|
71
|
+
: ref.start - lineStart + 1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lines = source.split('\n');
|
|
76
|
+
const lineIndex = Math.max((ref.line || 1) - 1, 0);
|
|
77
|
+
let lineStart = 0;
|
|
78
|
+
for (let i = 0; i < lineIndex; i += 1) lineStart += lines[i].length + 1;
|
|
79
|
+
const lineText = lines[lineIndex] ?? '';
|
|
80
|
+
const relativeStart = lineText.indexOf(ref.raw);
|
|
81
|
+
if (relativeStart === -1) {
|
|
82
|
+
throw new Error(`could not locate uses reference on line ${ref.line}`);
|
|
83
|
+
}
|
|
84
|
+
let start = lineStart + relativeStart;
|
|
85
|
+
let end = start + ref.raw.length;
|
|
86
|
+
const before = source[start - 1];
|
|
87
|
+
const after = source[end];
|
|
88
|
+
if ((before === '"' || before === "'") && after === before) {
|
|
89
|
+
start -= 1;
|
|
90
|
+
end += 1;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
...ref,
|
|
94
|
+
start,
|
|
95
|
+
end,
|
|
96
|
+
lineStart,
|
|
97
|
+
lineEnd: lineStart + lineText.length,
|
|
98
|
+
column: start - lineStart + 1,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build exact patches for one `uses:` scalar and its version metadata.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} source
|
|
106
|
+
* @param {object} ref
|
|
107
|
+
* @param {string} ref.raw
|
|
108
|
+
* @param {string} ref.owner
|
|
109
|
+
* @param {string} ref.repo
|
|
110
|
+
* @param {string|null} ref.subpath
|
|
111
|
+
* @param {number} ref.start
|
|
112
|
+
* @param {number} ref.end
|
|
113
|
+
* @param {number} ref.lineEnd
|
|
114
|
+
* @param {string} newRef
|
|
115
|
+
* @param {string} versionLabel
|
|
116
|
+
*/
|
|
117
|
+
export function planUsesPatches(source, ref, newRef, versionLabel) {
|
|
118
|
+
const located = locateActionRef(source, ref);
|
|
119
|
+
const left = located.subpath
|
|
120
|
+
? `${located.owner}/${located.repo}/${located.subpath}`
|
|
121
|
+
: `${located.owner}/${located.repo}`;
|
|
122
|
+
const nextValue = `${left}@${newRef}`;
|
|
123
|
+
const originalScalar = source.slice(located.start, located.end);
|
|
124
|
+
const quote = originalScalar[0] === '"' || originalScalar[0] === "'"
|
|
125
|
+
? originalScalar[0]
|
|
126
|
+
: '';
|
|
127
|
+
const scalarText = quote ? `${quote}${nextValue}${quote}` : nextValue;
|
|
128
|
+
const patches = [{
|
|
129
|
+
start: located.start,
|
|
130
|
+
end: located.end,
|
|
131
|
+
text: scalarText,
|
|
132
|
+
expected: originalScalar,
|
|
133
|
+
}];
|
|
134
|
+
|
|
135
|
+
if (versionLabel) {
|
|
136
|
+
const commentPatch = planVersionCommentPatch(source, located, versionLabel);
|
|
137
|
+
if (commentPatch) patches.push(commentPatch);
|
|
138
|
+
}
|
|
139
|
+
return patches;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read version metadata from either the new marker or the legacy `# v1.2.3`
|
|
144
|
+
* comment format.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} source
|
|
147
|
+
* @param {object} ref
|
|
148
|
+
*/
|
|
149
|
+
export function readVersionComment(source, ref) {
|
|
150
|
+
const located = locateActionRef(source, ref);
|
|
151
|
+
const trailing = source.slice(located.end, located.lineEnd);
|
|
152
|
+
const marker = trailing.match(/\bactions-warden-ref:\s*([^\s;#]+)/i);
|
|
153
|
+
if (marker) return marker[1];
|
|
154
|
+
const legacy = trailing.match(/^\s*#\s*(v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)\s*$/);
|
|
155
|
+
return legacy ? legacy[1] : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function planVersionCommentPatch(source, ref, versionLabel) {
|
|
159
|
+
const trailing = source.slice(ref.end, ref.lineEnd);
|
|
160
|
+
const marker = /\bactions-warden-ref:\s*([^\s;#]+)/i.exec(trailing);
|
|
161
|
+
if (marker) {
|
|
162
|
+
const valueOffset = marker.index + marker[0].lastIndexOf(marker[1]);
|
|
163
|
+
return {
|
|
164
|
+
start: ref.end + valueOffset,
|
|
165
|
+
end: ref.end + valueOffset + marker[1].length,
|
|
166
|
+
text: versionLabel,
|
|
167
|
+
expected: marker[1],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const legacy = /^(\s*#\s*)(v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)(\s*)$/.exec(trailing);
|
|
172
|
+
if (legacy) {
|
|
173
|
+
const valueOffset = legacy[1].length;
|
|
174
|
+
return {
|
|
175
|
+
start: ref.end + valueOffset,
|
|
176
|
+
end: ref.end + valueOffset + legacy[2].length,
|
|
177
|
+
text: versionLabel,
|
|
178
|
+
expected: legacy[2],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const hashOffset = trailing.indexOf('#');
|
|
183
|
+
if (hashOffset === -1) {
|
|
184
|
+
return {
|
|
185
|
+
start: ref.end,
|
|
186
|
+
end: ref.end,
|
|
187
|
+
text: ` # actions-warden-ref: ${versionLabel}`,
|
|
188
|
+
expected: '',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const insertAt = ref.end + hashOffset + 1;
|
|
193
|
+
return {
|
|
194
|
+
start: insertAt,
|
|
195
|
+
end: insertAt,
|
|
196
|
+
text: ` actions-warden-ref: ${versionLabel};`,
|
|
197
|
+
expected: '',
|
|
198
|
+
};
|
|
199
|
+
}
|
package/src/lib/paths.js
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Workflow file discovery with safe path handling.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
6
|
-
import { resolve, join, relative } from 'node:path';
|
|
5
|
+
import { readdir, stat, realpath } from 'node:fs/promises';
|
|
6
|
+
import { resolve, join, relative, isAbsolute, sep } from 'node:path';
|
|
7
7
|
import picomatch from 'picomatch';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Default workflow
|
|
10
|
+
* Default workflow directory globs.
|
|
11
11
|
*/
|
|
12
12
|
export const DEFAULT_WORKFLOW_PATTERNS = [
|
|
13
13
|
'.github/workflows/*.yml',
|
|
@@ -25,14 +25,30 @@ export const DEFAULT_WORKFLOW_PATTERNS = [
|
|
|
25
25
|
* @param {string} cwd
|
|
26
26
|
*/
|
|
27
27
|
function assertInside(p, cwd) {
|
|
28
|
+
if (typeof p !== 'string' || p.includes('\0')) {
|
|
29
|
+
throw new Error('invalid workflow path');
|
|
30
|
+
}
|
|
31
|
+
const root = resolve(cwd);
|
|
28
32
|
const abs = resolve(cwd, p);
|
|
29
|
-
const rel = relative(
|
|
30
|
-
if (
|
|
33
|
+
const rel = relative(root, abs);
|
|
34
|
+
if (isOutside(rel)) {
|
|
31
35
|
throw new Error(`path traversal rejected: ${p}`);
|
|
32
36
|
}
|
|
33
37
|
return abs;
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
function isOutside(rel) {
|
|
41
|
+
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function assertRealInside(path, cwd) {
|
|
45
|
+
const [root, target] = await Promise.all([realpath(resolve(cwd)), realpath(path)]);
|
|
46
|
+
if (isOutside(relative(root, target))) {
|
|
47
|
+
throw new Error(`symlink escape rejected: ${path}`);
|
|
48
|
+
}
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
/**
|
|
37
53
|
* Recursively list files under a directory.
|
|
38
54
|
*
|
|
@@ -69,12 +85,17 @@ async function walk(dir, acc = []) {
|
|
|
69
85
|
* @returns {Promise<string[]>}
|
|
70
86
|
*/
|
|
71
87
|
export async function discoverWorkflows({ patterns = DEFAULT_WORKFLOW_PATTERNS, cwd = process.cwd() } = {}) {
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
88
|
+
const root = resolve(cwd);
|
|
89
|
+
for (const p of patterns) assertInside(p, root);
|
|
90
|
+
const normalizedPatterns = patterns.map(p => {
|
|
91
|
+
const pattern = isAbsolute(p) ? relative(root, p) : p;
|
|
92
|
+
return pattern.split(sep).join('/');
|
|
93
|
+
});
|
|
94
|
+
const matchers = normalizedPatterns.map(p => picomatch(p, { dot: true }));
|
|
95
|
+
const all = await walk(root);
|
|
75
96
|
const out = [];
|
|
76
97
|
for (const file of all) {
|
|
77
|
-
const rel = relative(
|
|
98
|
+
const rel = relative(root, file).split(sep).join('/');
|
|
78
99
|
if (matchers.some(m => m(rel))) out.push(file);
|
|
79
100
|
}
|
|
80
101
|
return out.sort();
|
|
@@ -92,11 +113,13 @@ export async function resolveWorkflowArg(input, cwd = process.cwd()) {
|
|
|
92
113
|
const abs = assertInside(input, cwd);
|
|
93
114
|
try {
|
|
94
115
|
const st = await stat(abs);
|
|
116
|
+
const safePath = await assertRealInside(abs, cwd);
|
|
95
117
|
if (st.isDirectory()) {
|
|
96
|
-
const files = await walk(
|
|
97
|
-
return files.filter(f => /\.ya?ml
|
|
118
|
+
const files = await walk(safePath);
|
|
119
|
+
return files.filter(f => /\.ya?ml$/i.test(f)).sort();
|
|
98
120
|
}
|
|
99
|
-
return [
|
|
121
|
+
if (!st.isFile()) return [];
|
|
122
|
+
return [safePath];
|
|
100
123
|
} catch {
|
|
101
124
|
return discoverWorkflows({ patterns: [input], cwd });
|
|
102
125
|
}
|
package/src/lib/redact.js
CHANGED
|
@@ -12,12 +12,25 @@ const TOKEN_PATTERNS = [
|
|
|
12
12
|
/ghu_[A-Za-z0-9]{30,}/g,
|
|
13
13
|
/github_pat_[A-Za-z0-9_]{30,}/g,
|
|
14
14
|
/xox[abprs]-[A-Za-z0-9-]{10,}/g,
|
|
15
|
-
/AKIA[0-9A-Z]{16}/g,
|
|
15
|
+
/(?:AKIA|ASIA)[0-9A-Z]{16}/g,
|
|
16
16
|
/sk-[A-Za-z0-9]{20,}/g,
|
|
17
|
+
/(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
18
|
+
/AIza[0-9A-Za-z_-]{30,}/g,
|
|
19
|
+
/ya29\.[0-9A-Za-z_-]{20,}/g,
|
|
20
|
+
/npm_[0-9A-Za-z]{20,}/g,
|
|
21
|
+
/eyJ[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}/g,
|
|
17
22
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
18
23
|
];
|
|
19
24
|
|
|
20
|
-
const
|
|
25
|
+
const SENSITIVE_KEY = /(?:token|secret|password|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key)$/i;
|
|
26
|
+
const KV_TOKEN_KEYS = /([\w-]*(?:token|secret|password|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key))(\s*[:=]\s*)["']?([^"'\s,]+)/gi;
|
|
27
|
+
const HIGH_ENTROPY = /[A-Za-z0-9_+/-]{32,}={0,2}/g;
|
|
28
|
+
// This public rule ID is long enough to trip the generic entropy heuristic.
|
|
29
|
+
// Keep the exception exact instead of weakening detection for arbitrary
|
|
30
|
+
// kebab-case values, which may still be credentials or passphrases.
|
|
31
|
+
const SAFE_PUBLIC_VALUES = new Set([
|
|
32
|
+
'reusable-workflow-secrets-inherit',
|
|
33
|
+
]);
|
|
21
34
|
|
|
22
35
|
/**
|
|
23
36
|
* Redacts sensitive substrings from a value.
|
|
@@ -29,10 +42,58 @@ export function redact(input) {
|
|
|
29
42
|
if (input == null) return '';
|
|
30
43
|
let s = typeof input === 'string' ? input : String(input);
|
|
31
44
|
for (const re of TOKEN_PATTERNS) s = s.replace(re, '<redacted>');
|
|
32
|
-
s = s.replace(KV_TOKEN_KEYS, (_, key) => `${key}
|
|
45
|
+
s = s.replace(KV_TOKEN_KEYS, (_, key, separator) => `${key}${separator}<redacted>`);
|
|
46
|
+
s = s.replace(HIGH_ENTROPY, value => (
|
|
47
|
+
shouldRedactHighEntropy(value) ? '<redacted>' : value
|
|
48
|
+
));
|
|
33
49
|
return s;
|
|
34
50
|
}
|
|
35
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Recursively redact strings in a JSON-compatible value.
|
|
54
|
+
*
|
|
55
|
+
* @param {unknown} input
|
|
56
|
+
* @param {WeakSet<object>} [seen]
|
|
57
|
+
* @returns {unknown}
|
|
58
|
+
*/
|
|
59
|
+
export function redactDeep(input, seen = new WeakSet()) {
|
|
60
|
+
if (typeof input === 'string') return redact(input);
|
|
61
|
+
if (input === null || typeof input !== 'object') return input;
|
|
62
|
+
if (seen.has(input)) return '<circular>';
|
|
63
|
+
seen.add(input);
|
|
64
|
+
let output;
|
|
65
|
+
if (Array.isArray(input)) {
|
|
66
|
+
output = input.map(item => redactDeep(item, seen));
|
|
67
|
+
} else {
|
|
68
|
+
output = {};
|
|
69
|
+
for (const [key, value] of Object.entries(input)) {
|
|
70
|
+
output[key] = SENSITIVE_KEY.test(key) ? '<redacted>' : redactDeep(value, seen);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
seen.delete(input);
|
|
74
|
+
return output;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function shouldRedactHighEntropy(value) {
|
|
78
|
+
if (SAFE_PUBLIC_VALUES.has(value)) return false;
|
|
79
|
+
if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)) return false;
|
|
80
|
+
const categories = [
|
|
81
|
+
/[a-z]/.test(value),
|
|
82
|
+
/[A-Z]/.test(value),
|
|
83
|
+
/\d/.test(value),
|
|
84
|
+
/[_+/=-]/.test(value),
|
|
85
|
+
].filter(Boolean).length;
|
|
86
|
+
if (categories < 2) return false;
|
|
87
|
+
const frequencies = new Map();
|
|
88
|
+
for (const char of value) frequencies.set(char, (frequencies.get(char) ?? 0) + 1);
|
|
89
|
+
let entropy = 0;
|
|
90
|
+
for (const count of frequencies.values()) {
|
|
91
|
+
const probability = count / value.length;
|
|
92
|
+
entropy -= probability * Math.log2(probability);
|
|
93
|
+
}
|
|
94
|
+
return entropy >= 3.5;
|
|
95
|
+
}
|
|
96
|
+
|
|
36
97
|
/**
|
|
37
98
|
* Wraps console.error/log so any string-coerced argument is redacted first.
|
|
38
99
|
*
|
|
@@ -40,5 +101,5 @@ export function redact(input) {
|
|
|
40
101
|
* @returns {(...args: unknown[]) => void}
|
|
41
102
|
*/
|
|
42
103
|
export function safeLogger(fn) {
|
|
43
|
-
return (...args) => fn(...args.map(a => (
|
|
104
|
+
return (...args) => fn(...args.map(a => redactDeep(a)));
|
|
44
105
|
}
|