start-command 0.39.0 → 0.39.2
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/CHANGELOG.md +14 -0
- package/README.md +2 -1
- package/package.json +2 -1
- package/src/lib/cgroup-memory.js +35 -11
- package/src/lib/failure-handler.js +12 -6
- package/src/lib/log-sanitizer.js +303 -220
- package/src/lib/log-uploader.js +6 -3
- package/src/lib/screen-isolation.js +72 -42
- package/test/isolation.js +32 -0
- package/test/log-sanitizer.js +302 -135
- package/test/regression-247.js +140 -0
package/src/lib/log-sanitizer.js
CHANGED
|
@@ -1,267 +1,350 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Prepares logs and issue text for publication with
|
|
3
|
+
* @link-foundation/sensitive-data-sanitizer, the sanitizer shared by
|
|
4
|
+
* start-command, gh-upload-log and hive-mind (issue #250).
|
|
5
|
+
*
|
|
6
|
+
* The library masks credentials only (--credentials-only), plus the values of
|
|
7
|
+
* credential-named environment variables (--env-secrets) and the tokens in the
|
|
8
|
+
* local gh hosts.yml (--gh-config). It streams the log from stdin, keeps
|
|
9
|
+
* invalid UTF-8 bytes and writes a new private file atomically (--output).
|
|
10
|
+
*
|
|
11
|
+
* One rule stays here: the library masks an unquoted or single-quoted
|
|
12
|
+
* `Authorization: <scheme> <value>` to the end of the line, which drops the
|
|
13
|
+
* rest of a logged one-line JSON prompt (issue #249). The pre-pass below masks
|
|
14
|
+
* only the scheme and its token first; the library keeps values that already
|
|
15
|
+
* start with [REDACTED]. Remove it with sensitive-data-sanitizer#45.
|
|
16
|
+
*/
|
|
17
|
+
|
|
2
18
|
const fs = require('fs');
|
|
3
19
|
const os = require('os');
|
|
4
20
|
const path = require('path');
|
|
21
|
+
const { spawnSync } = require('child_process');
|
|
5
22
|
|
|
6
23
|
const BLOCK_SIZE = 64 * 1024;
|
|
7
24
|
const MARKER = '[REDACTED]';
|
|
8
|
-
const
|
|
9
|
-
|
|
25
|
+
const PACKAGE = '@link-foundation/sensitive-data-sanitizer';
|
|
26
|
+
const COMMAND = 'sensitive-data-sanitizer';
|
|
27
|
+
const OVERRIDE = 'START_SENSITIVE_DATA_SANITIZER';
|
|
28
|
+
const OPTIONS = ['--credentials-only', '--env-secrets', '--gh-config'];
|
|
10
29
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/\b[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{0,512}/g,
|
|
18
|
-
validate: (value) => {
|
|
19
|
-
try {
|
|
20
|
-
return (
|
|
21
|
-
typeof JSON.parse(
|
|
22
|
-
Buffer.from(value.split('.')[0], 'base64url').toString()
|
|
23
|
-
).alg === 'string'
|
|
24
|
-
);
|
|
25
|
-
} catch {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
},
|
|
29
|
-
continuation: /[A-Za-z0-9_.-]/,
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
pattern: /[A-Za-z0-9_-]{512,}[A-Za-z0-9_.-]*/g,
|
|
33
|
-
continuation: /[A-Za-z0-9_.-]/,
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
pattern: /(?:gh[pousr]_|github_pat_|sk-)[A-Za-z0-9_-]*/g,
|
|
37
|
-
continuation: /[A-Za-z0-9_-]/,
|
|
38
|
-
},
|
|
39
|
-
{ pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/g },
|
|
40
|
-
{ pattern: /eyJ[A-Za-z0-9_.-]*/g, continuation: /[A-Za-z0-9_.-]/ },
|
|
41
|
-
{
|
|
42
|
-
pattern: /(?:Proxy-)?Authorization[ \t]{0,32}:[^\r\n]*/gi,
|
|
43
|
-
continuation: /[^\r\n]/,
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
pattern:
|
|
47
|
-
/(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)[ \t]{0,32}[=:][ \t]*["']?[^\s"';,]*/g,
|
|
48
|
-
continuation: /[^\s"';,]/,
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
pattern: /(?:https?|git|ssh):\/\/[^\s:/@]{1,256}:[^\s@]*/g,
|
|
52
|
-
continuation: /[^\s@]/,
|
|
53
|
-
},
|
|
54
|
-
];
|
|
30
|
+
// The longest header and scheme prefix is 122 bytes.
|
|
31
|
+
const HOLDBACK = 160;
|
|
32
|
+
const HEADER =
|
|
33
|
+
/((?:Proxy-)?Authorization[ \t]{0,32}:[ \t]{0,32})(?:Bearer|Basic|Digest|token)[ \t]{1,32}/gi;
|
|
34
|
+
// Shell expressions, placeholders and masked values are not credentials.
|
|
35
|
+
const NOT_A_VALUE = '$<[{';
|
|
55
36
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (this.secrets.some((value) => value.length > BLOCK_SIZE)) {
|
|
71
|
-
throw new Error(
|
|
72
|
-
'Known environment secret is too large for bounded sanitization.'
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
this.holdback = this.secrets.reduce(
|
|
76
|
-
(size, value) => Math.max(size, value.length),
|
|
77
|
-
2048
|
|
78
|
-
);
|
|
79
|
-
this.pending = '';
|
|
80
|
-
this.continuation = null;
|
|
81
|
-
this.redactions = 0;
|
|
37
|
+
function isValueByte(code) {
|
|
38
|
+
return (
|
|
39
|
+
code > 0x20 &&
|
|
40
|
+
code !== 0x22 &&
|
|
41
|
+
code !== 0x27 &&
|
|
42
|
+
code !== 0x2c &&
|
|
43
|
+
code !== 0x5c
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function valueEnd(text, start) {
|
|
48
|
+
let end = start;
|
|
49
|
+
while (end < text.length && isValueByte(text.charCodeAt(end))) {
|
|
50
|
+
end++;
|
|
82
51
|
}
|
|
52
|
+
return end;
|
|
53
|
+
}
|
|
83
54
|
|
|
84
|
-
|
|
85
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Masks `Authorization: <scheme> <token>` as `Authorization: [REDACTED]`.
|
|
57
|
+
* It works on bytes (latin1 maps each byte to one character), so invalid
|
|
58
|
+
* UTF-8 passes through unchanged, and holds at most HOLDBACK pending bytes.
|
|
59
|
+
*/
|
|
60
|
+
class AuthorizationHeaderMask {
|
|
61
|
+
constructor() {
|
|
62
|
+
this.pending = '';
|
|
63
|
+
this.inValue = false;
|
|
64
|
+
this.redactions = 0;
|
|
86
65
|
}
|
|
87
66
|
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (!rule.validate || rule.validate(match[0])) {
|
|
98
|
-
matches.push({
|
|
99
|
-
index: match.index,
|
|
100
|
-
end: match.index + match[0].length,
|
|
101
|
-
continuation: rule.continuation,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
67
|
+
push(block, final = false) {
|
|
68
|
+
const text = this.pending + block.toString('latin1');
|
|
69
|
+
this.pending = '';
|
|
70
|
+
let output = '';
|
|
71
|
+
let offset = 0;
|
|
72
|
+
if (this.inValue) {
|
|
73
|
+
offset = valueEnd(text, 0);
|
|
74
|
+
if (offset === text.length && !final) {
|
|
75
|
+
return Buffer.alloc(0);
|
|
104
76
|
}
|
|
77
|
+
this.inValue = false;
|
|
105
78
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
79
|
+
HEADER.lastIndex = offset;
|
|
80
|
+
let match;
|
|
81
|
+
while ((match = HEADER.exec(text))) {
|
|
82
|
+
const valueStart = match.index + match[0].length;
|
|
83
|
+
if (valueStart === text.length && !final) {
|
|
84
|
+
output += text.slice(offset, match.index);
|
|
85
|
+
this.pending = text.slice(match.index);
|
|
86
|
+
return Buffer.from(output, 'latin1');
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
valueStart === text.length ||
|
|
90
|
+
!isValueByte(text.charCodeAt(valueStart)) ||
|
|
91
|
+
NOT_A_VALUE.includes(text[valueStart])
|
|
111
92
|
) {
|
|
112
|
-
|
|
93
|
+
continue;
|
|
113
94
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (match.end > previous.end) {
|
|
121
|
-
previous.end = match.end;
|
|
122
|
-
previous.continuation = match.continuation;
|
|
123
|
-
} else if (match.end === previous.end && match.continuation) {
|
|
124
|
-
// Nested tokens must not narrow a containing header/URL's suffix.
|
|
125
|
-
const retained = previous.continuation;
|
|
126
|
-
const additional = match.continuation;
|
|
127
|
-
previous.continuation = retained
|
|
128
|
-
? { test: (byte) => retained.test(byte) || additional.test(byte) }
|
|
129
|
-
: additional;
|
|
130
|
-
}
|
|
131
|
-
} else {
|
|
132
|
-
merged.push({ ...match });
|
|
95
|
+
output += text.slice(offset, match.index + match[1].length) + MARKER;
|
|
96
|
+
this.redactions++;
|
|
97
|
+
offset = valueEnd(text, valueStart);
|
|
98
|
+
if (offset === text.length && !final) {
|
|
99
|
+
this.inValue = true;
|
|
100
|
+
return Buffer.from(output, 'latin1');
|
|
133
101
|
}
|
|
102
|
+
HEADER.lastIndex = offset;
|
|
134
103
|
}
|
|
135
|
-
|
|
104
|
+
const safeEnd = final
|
|
105
|
+
? text.length
|
|
106
|
+
: Math.max(offset, text.length - HOLDBACK);
|
|
107
|
+
output += text.slice(offset, safeEnd);
|
|
108
|
+
this.pending = text.slice(safeEnd);
|
|
109
|
+
return Buffer.from(output, 'latin1');
|
|
136
110
|
}
|
|
111
|
+
}
|
|
137
112
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
113
|
+
/** Value-free failure: `code` is a fixed ERR_* name, never log content. */
|
|
114
|
+
class SanitizationError extends Error {
|
|
115
|
+
constructor(code, subject = 'Log') {
|
|
116
|
+
super(
|
|
117
|
+
`${subject} sanitization failed (${code}); ${subject === 'Log' ? 'upload' : 'publication'} blocked.`
|
|
118
|
+
);
|
|
119
|
+
this.code = code;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isFile(filePath) {
|
|
124
|
+
try {
|
|
125
|
+
return fs.statSync(filePath).isFile();
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findBundledCli() {
|
|
132
|
+
// Walk up node_modules directories the way require() does; the package
|
|
133
|
+
// exports only ESM, so require.resolve() cannot locate its files.
|
|
134
|
+
const relative = path.join(PACKAGE, 'bin', `${COMMAND}.js`);
|
|
135
|
+
let directory = __dirname;
|
|
136
|
+
for (;;) {
|
|
137
|
+
const candidate = path.join(directory, 'node_modules', relative);
|
|
138
|
+
if (isFile(candidate)) {
|
|
139
|
+
return candidate;
|
|
151
140
|
}
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
141
|
+
const parent = path.dirname(directory);
|
|
142
|
+
if (parent === directory) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
directory = parent;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the library CLI: START_SENSITIVE_DATA_SANITIZER, then the bundled
|
|
151
|
+
* dependency run by the current runtime, then sensitive-data-sanitizer on PATH.
|
|
152
|
+
* @returns {{command: string, args: string[]}|null}
|
|
153
|
+
*/
|
|
154
|
+
function resolveSanitizerCommand(env = process.env) {
|
|
155
|
+
if (env[OVERRIDE]) {
|
|
156
|
+
return { command: env[OVERRIDE], args: [] };
|
|
157
|
+
}
|
|
158
|
+
const bundled = findBundledCli();
|
|
159
|
+
if (bundled) {
|
|
160
|
+
return { command: process.execPath, args: [bundled] };
|
|
161
|
+
}
|
|
162
|
+
const { resolveCommand } = require('./log-uploader');
|
|
163
|
+
const command = resolveCommand(COMMAND);
|
|
164
|
+
return command ? { command, args: [] } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function runSanitizer(args, options, subject) {
|
|
168
|
+
const env = options.env || process.env;
|
|
169
|
+
const resolved = resolveSanitizerCommand(env);
|
|
170
|
+
if (!resolved) {
|
|
171
|
+
throw new SanitizationError('ERR_UNAVAILABLE', subject);
|
|
172
|
+
}
|
|
173
|
+
const result = spawnSync(resolved.command, [...resolved.args, ...args], {
|
|
174
|
+
env,
|
|
175
|
+
input: options.input,
|
|
176
|
+
stdio: options.stdio || ['pipe', 'pipe', 'pipe'],
|
|
177
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
178
|
+
shell:
|
|
179
|
+
process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command),
|
|
180
|
+
windowsHide: true,
|
|
181
|
+
});
|
|
182
|
+
if (result.error) {
|
|
183
|
+
throw new SanitizationError('ERR_UNAVAILABLE', subject);
|
|
184
|
+
}
|
|
185
|
+
if (result.status !== 0) {
|
|
186
|
+
// The library prints value-free messages; only their ERR_* code is kept.
|
|
187
|
+
const code = String(result.stderr || '').match(/\bERR_[A-Z_]+\b/);
|
|
188
|
+
throw new SanitizationError(code ? code[0] : 'ERR_SANITIZER', subject);
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Sanitize a short text, such as the failing command quoted in an issue.
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function sanitizeText(text, options = {}) {
|
|
198
|
+
const input = new AuthorizationHeaderMask().push(Buffer.from(text), true);
|
|
199
|
+
const result = runSanitizer(
|
|
200
|
+
['redact', '-', ...OPTIONS],
|
|
201
|
+
{ ...options, input },
|
|
202
|
+
'Text'
|
|
203
|
+
);
|
|
204
|
+
return result.stdout.toString();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function countMarkers(filePath) {
|
|
208
|
+
const descriptor = fs.openSync(filePath, 'r');
|
|
209
|
+
const buffer = Buffer.alloc(BLOCK_SIZE);
|
|
210
|
+
let count = 0;
|
|
211
|
+
let carry = '';
|
|
212
|
+
try {
|
|
213
|
+
for (;;) {
|
|
214
|
+
const length = fs.readSync(descriptor, buffer, 0, BLOCK_SIZE, null);
|
|
215
|
+
const text = carry + buffer.toString('latin1', 0, length);
|
|
216
|
+
let index = text.indexOf(MARKER);
|
|
217
|
+
let next = 0;
|
|
218
|
+
while (index !== -1) {
|
|
219
|
+
count++;
|
|
220
|
+
next = index + MARKER.length;
|
|
221
|
+
index = text.indexOf(MARKER, next);
|
|
160
222
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
offset = match.end;
|
|
164
|
-
if (offset === input.length && match.continuation && !final) {
|
|
165
|
-
this.continuation = match.continuation;
|
|
166
|
-
break;
|
|
223
|
+
if (length === 0) {
|
|
224
|
+
return count;
|
|
167
225
|
}
|
|
226
|
+
carry = text.slice(Math.max(next, text.length - MARKER.length + 1));
|
|
168
227
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
this.pending = input.slice(end);
|
|
172
|
-
return Buffer.from(output.join(''), 'latin1');
|
|
228
|
+
} finally {
|
|
229
|
+
fs.closeSync(descriptor);
|
|
173
230
|
}
|
|
174
231
|
}
|
|
175
232
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
233
|
+
function createPrivateFile(filePath) {
|
|
234
|
+
const descriptor = fs.openSync(filePath, 'wx', 0o600);
|
|
235
|
+
if (process.platform !== 'win32') {
|
|
236
|
+
fs.fchmodSync(descriptor, 0o600);
|
|
237
|
+
}
|
|
238
|
+
return descriptor;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function maskAuthorizationHeaders(sourcePath, stagePath) {
|
|
242
|
+
const source = fs.openSync(sourcePath, 'r');
|
|
243
|
+
let stage;
|
|
183
244
|
try {
|
|
184
|
-
const sanitizer = new StreamingSanitizer(options.env || process.env);
|
|
185
|
-
stage = 'source';
|
|
186
|
-
source = fs.openSync(sourcePath, 'r');
|
|
187
245
|
if (!fs.fstatSync(source).isFile()) {
|
|
188
|
-
throw new
|
|
246
|
+
throw new SanitizationError('ERR_SOURCE');
|
|
189
247
|
}
|
|
190
|
-
stage =
|
|
191
|
-
|
|
192
|
-
fs.chmodSync(directory, 0o700);
|
|
193
|
-
const target = path.join(directory, 'execution.log');
|
|
194
|
-
destination = fs.openSync(target, 'wx', 0o600);
|
|
195
|
-
fs.fchmodSync(destination, 0o600);
|
|
248
|
+
stage = createPrivateFile(stagePath);
|
|
249
|
+
const mask = new AuthorizationHeaderMask();
|
|
196
250
|
const buffer = Buffer.alloc(BLOCK_SIZE);
|
|
197
251
|
let bytes = 0;
|
|
198
|
-
const write = (output) => {
|
|
199
|
-
stage = 'write';
|
|
200
|
-
let offset = 0;
|
|
201
|
-
while (offset < output.length) {
|
|
202
|
-
const written = fs.writeSync(
|
|
203
|
-
destination,
|
|
204
|
-
output,
|
|
205
|
-
offset,
|
|
206
|
-
output.length - offset
|
|
207
|
-
);
|
|
208
|
-
if (!written) {
|
|
209
|
-
throw new Error('No progress writing sanitized log.');
|
|
210
|
-
}
|
|
211
|
-
offset += written;
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
252
|
for (;;) {
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
253
|
+
const length = fs.readSync(source, buffer, 0, BLOCK_SIZE, null);
|
|
254
|
+
const output = mask.push(buffer.subarray(0, length), length === 0);
|
|
255
|
+
if (output.length) {
|
|
256
|
+
fs.writeSync(stage, output);
|
|
257
|
+
}
|
|
258
|
+
if (length === 0) {
|
|
218
259
|
break;
|
|
219
260
|
}
|
|
220
261
|
bytes += length;
|
|
221
|
-
stage = 'scan';
|
|
222
|
-
write(sanitizer.push(buffer.subarray(0, length)));
|
|
223
262
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
fs.closeSync(destination);
|
|
228
|
-
destination = undefined;
|
|
263
|
+
fs.fsyncSync(stage);
|
|
264
|
+
return bytes;
|
|
265
|
+
} finally {
|
|
229
266
|
fs.closeSync(source);
|
|
230
|
-
|
|
267
|
+
if (stage !== undefined) {
|
|
268
|
+
fs.closeSync(stage);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Create a private sanitized copy of a log. The source is never modified;
|
|
275
|
+
* any failure removes the copy and throws a value-free SanitizationError.
|
|
276
|
+
* @returns {{path: string, cleanup: Function}}
|
|
277
|
+
*/
|
|
278
|
+
function sanitizeLogToTemp(sourcePath, options = {}) {
|
|
279
|
+
let directory;
|
|
280
|
+
let code = 'ERR_PRIVATE_FILE';
|
|
281
|
+
const cleanup = () => {
|
|
282
|
+
if (directory) {
|
|
283
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'start-sanitized-'));
|
|
288
|
+
if (process.platform !== 'win32') {
|
|
289
|
+
fs.chmodSync(directory, 0o700);
|
|
290
|
+
}
|
|
291
|
+
const stagePath = path.join(directory, 'authorization-masked.log');
|
|
292
|
+
const target = path.join(directory, 'execution.log');
|
|
293
|
+
code = 'ERR_SOURCE';
|
|
294
|
+
const bytes = maskAuthorizationHeaders(sourcePath, stagePath);
|
|
295
|
+
code = 'ERR_PRIVATE_FILE';
|
|
296
|
+
const input = fs.openSync(stagePath, 'r');
|
|
297
|
+
try {
|
|
298
|
+
const size = Math.max(1, fs.fstatSync(input).size);
|
|
299
|
+
runSanitizer(
|
|
300
|
+
[
|
|
301
|
+
'redact',
|
|
302
|
+
'-',
|
|
303
|
+
'--stream',
|
|
304
|
+
...OPTIONS,
|
|
305
|
+
'--max-bytes',
|
|
306
|
+
String(size),
|
|
307
|
+
'--output',
|
|
308
|
+
target,
|
|
309
|
+
],
|
|
310
|
+
{ env: options.env, stdio: [input, 'ignore', 'pipe'] }
|
|
311
|
+
);
|
|
312
|
+
} finally {
|
|
313
|
+
fs.closeSync(input);
|
|
314
|
+
}
|
|
315
|
+
code = 'ERR_OUTPUT';
|
|
316
|
+
const status = fs.lstatSync(target);
|
|
317
|
+
if (
|
|
318
|
+
!status.isFile() ||
|
|
319
|
+
(process.platform !== 'win32' && (status.mode & 0o077) !== 0)
|
|
320
|
+
) {
|
|
321
|
+
throw new SanitizationError('ERR_OUTPUT');
|
|
322
|
+
}
|
|
231
323
|
if (options.verbose) {
|
|
324
|
+
const redactions = countMarkers(target) - countMarkers(sourcePath);
|
|
232
325
|
console.log(
|
|
233
|
-
`Prepared private sanitized log: ${bytes} bytes scanned, ${
|
|
326
|
+
`Prepared private sanitized log: ${bytes} bytes scanned, ${Math.max(0, redactions)} redactions.`
|
|
234
327
|
);
|
|
235
328
|
}
|
|
236
|
-
|
|
237
|
-
return {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
329
|
+
fs.rmSync(stagePath, { force: true });
|
|
330
|
+
return { path: target, cleanup };
|
|
331
|
+
} catch (error) {
|
|
332
|
+
cleanup();
|
|
333
|
+
// Static messages keep paths and system error details out of the output.
|
|
334
|
+
const failure =
|
|
335
|
+
error instanceof SanitizationError ? error : new SanitizationError(code);
|
|
242
336
|
if (options.verbose) {
|
|
243
|
-
console.
|
|
244
|
-
}
|
|
245
|
-
// Do not echo error messages: paths and exception details can carry secrets.
|
|
246
|
-
throw new Error('Log sanitization failed; upload blocked.');
|
|
247
|
-
} finally {
|
|
248
|
-
for (const fd of [destination, source]) {
|
|
249
|
-
if (fd !== undefined) {
|
|
250
|
-
try {
|
|
251
|
-
fs.closeSync(fd);
|
|
252
|
-
} catch {
|
|
253
|
-
/* Best effort after failure. */
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (directory && !ready) {
|
|
258
|
-
try {
|
|
259
|
-
fs.rmSync(directory, { recursive: true, force: true });
|
|
260
|
-
} catch {
|
|
261
|
-
/* Upload remains blocked. */
|
|
262
|
-
}
|
|
337
|
+
console.error(failure.message);
|
|
263
338
|
}
|
|
339
|
+
throw failure;
|
|
264
340
|
}
|
|
265
341
|
}
|
|
266
342
|
|
|
267
|
-
module.exports = {
|
|
343
|
+
module.exports = {
|
|
344
|
+
AuthorizationHeaderMask,
|
|
345
|
+
BLOCK_SIZE,
|
|
346
|
+
SanitizationError,
|
|
347
|
+
resolveSanitizerCommand,
|
|
348
|
+
sanitizeLogToTemp,
|
|
349
|
+
sanitizeText,
|
|
350
|
+
};
|
package/src/lib/log-uploader.js
CHANGED
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { spawnSync } = require('child_process');
|
|
9
|
-
const { sanitizeLogToTemp } = require('./log-sanitizer');
|
|
9
|
+
const { SanitizationError, sanitizeLogToTemp } = require('./log-sanitizer');
|
|
10
10
|
|
|
11
11
|
function isExecutable(filePath) {
|
|
12
12
|
try {
|
|
@@ -174,11 +174,14 @@ function uploadLogPath(logPath, options = {}) {
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
return { success: true, exitCode: 0 };
|
|
177
|
-
} catch {
|
|
177
|
+
} catch (error) {
|
|
178
178
|
return {
|
|
179
179
|
success: false,
|
|
180
180
|
exitCode: 1,
|
|
181
|
-
error:
|
|
181
|
+
error:
|
|
182
|
+
error instanceof SanitizationError
|
|
183
|
+
? error.message
|
|
184
|
+
: 'Log preparation or upload failed; upload blocked.',
|
|
182
185
|
};
|
|
183
186
|
} finally {
|
|
184
187
|
if (prepared) {
|