start-command 0.39.1 → 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 +6 -0
- package/README.md +2 -1
- package/package.json +2 -1
- 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/test/log-sanitizer.js +302 -135
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.39.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 328fa0c: Sanitize `--upload-log` logs and auto-reported issue text with `@link-foundation/sensitive-data-sanitizer` (#250) instead of start-command's own rules, which removed content that is not secret (#249). Before, `Authorization:` was masked to the end of the line, which dropped the rest of one-line JSON request bodies. Every URL with a port was masked, and `sk-` matched inside words such as `disk-baseline-`. Now only credentials are masked (`--credentials-only`): values of credential-named environment variables, the gh `hosts.yml` tokens, and `Authorization` schemes with their token. The rest of the line, account details and URLs with ports stay. The log streams through the library CLI from stdin into a private `0600` copy, and invalid UTF-8 bytes are kept. Any failure blocks the upload with a value-free code such as `Log sanitization failed (ERR_BINARY); upload blocked.` `START_SENSITIVE_DATA_SANITIZER` selects another CLI, and `--no-sanitize` is unchanged.
|
|
8
|
+
|
|
3
9
|
## 0.39.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -41,7 +41,8 @@ checked examples.
|
|
|
41
41
|
|
|
42
42
|
`-h` also prints usage. Labels persist across Docker resumes. New detached
|
|
43
43
|
containers support copy-free command handoff; legacy snapshots require capacity
|
|
44
|
-
preflight. Log uploads are private and sanitized
|
|
44
|
+
preflight. Log uploads are private and sanitized with the bundled
|
|
45
|
+
`@link-foundation/sensitive-data-sanitizer`; manual `--no-sanitize` opts out.
|
|
45
46
|
|
|
46
47
|
Snapshot resumes take `--snapshot-wait`, `--snapshot-disk-wait`,
|
|
47
48
|
`--remove-original-after`, `--snapshot-size-hint` and `--snapshot-prepare`, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "start-command",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.2",
|
|
4
4
|
"description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
|
|
5
5
|
"main": "src/bin/cli.js",
|
|
6
6
|
"exports": {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
]
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
+
"@link-foundation/sensitive-data-sanitizer": "^0.12.0",
|
|
71
72
|
"command-stream": "^0.9.4",
|
|
72
73
|
"lino-objects-codec": "^0.4.0"
|
|
73
74
|
},
|
|
@@ -9,7 +9,11 @@ const os = require('os');
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { getTimestamp } = require('./isolation');
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
SanitizationError,
|
|
14
|
+
sanitizeLogToTemp,
|
|
15
|
+
sanitizeText,
|
|
16
|
+
} = require('./log-sanitizer');
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* Handle command failure - detect repository, upload log, create issue
|
|
@@ -296,8 +300,12 @@ function uploadLog(logPath, options = {}) {
|
|
|
296
300
|
}
|
|
297
301
|
|
|
298
302
|
return null;
|
|
299
|
-
} catch {
|
|
300
|
-
console.log(
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.log(
|
|
305
|
+
error instanceof SanitizationError
|
|
306
|
+
? `Warning: ${error.message}`
|
|
307
|
+
: 'Warning: Log preparation or upload failed; upload blocked.'
|
|
308
|
+
);
|
|
301
309
|
return null;
|
|
302
310
|
} finally {
|
|
303
311
|
if (prepared) {
|
|
@@ -334,9 +342,7 @@ function canCreateIssue(owner, repo) {
|
|
|
334
342
|
*/
|
|
335
343
|
function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
|
|
336
344
|
try {
|
|
337
|
-
fullCommand =
|
|
338
|
-
.push(Buffer.from(fullCommand), true)
|
|
339
|
-
.toString();
|
|
345
|
+
fullCommand = sanitizeText(fullCommand);
|
|
340
346
|
const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
|
|
341
347
|
|
|
342
348
|
// Get runtime information
|
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) {
|
package/test/log-sanitizer.js
CHANGED
|
@@ -6,156 +6,238 @@ const os = require('os');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { execFileSync } = require('child_process');
|
|
8
8
|
const {
|
|
9
|
+
AuthorizationHeaderMask,
|
|
10
|
+
SanitizationError,
|
|
11
|
+
resolveSanitizerCommand,
|
|
9
12
|
sanitizeLogToTemp,
|
|
10
|
-
|
|
13
|
+
sanitizeText,
|
|
11
14
|
} = require('../src/lib/log-sanitizer');
|
|
12
15
|
|
|
16
|
+
const GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-gh-'));
|
|
17
|
+
|
|
18
|
+
// Only what the runtime needs, so the host's own credential-named variables
|
|
19
|
+
// and gh login cannot change the expected output.
|
|
20
|
+
function testEnv(extra = {}) {
|
|
21
|
+
const env = { GH_CONFIG_DIR };
|
|
22
|
+
for (const name of [
|
|
23
|
+
'PATH',
|
|
24
|
+
'Path',
|
|
25
|
+
'SystemRoot',
|
|
26
|
+
'SYSTEMROOT',
|
|
27
|
+
'TEMP',
|
|
28
|
+
'TMP',
|
|
29
|
+
'TMPDIR',
|
|
30
|
+
'HOME',
|
|
31
|
+
'USERPROFILE',
|
|
32
|
+
]) {
|
|
33
|
+
if (process.env[name] !== undefined) {
|
|
34
|
+
env[name] = process.env[name];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ...env, ...extra };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function withLog(content, callback) {
|
|
41
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-test-'));
|
|
42
|
+
const source = path.join(dir, 'source.log');
|
|
43
|
+
fs.writeFileSync(source, content);
|
|
44
|
+
try {
|
|
45
|
+
return callback(source, dir);
|
|
46
|
+
} finally {
|
|
47
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sanitizeFile(content, env = testEnv()) {
|
|
52
|
+
return withLog(content, (source) => {
|
|
53
|
+
const copy = sanitizeLogToTemp(source, { env });
|
|
54
|
+
try {
|
|
55
|
+
return fs.readFileSync(copy.path);
|
|
56
|
+
} finally {
|
|
57
|
+
copy.cleanup();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mask(input, blockSize) {
|
|
63
|
+
const authorization = new AuthorizationHeaderMask();
|
|
64
|
+
const blocks = [];
|
|
65
|
+
for (let offset = 0; offset < input.length; offset += blockSize) {
|
|
66
|
+
blocks.push(authorization.push(input.subarray(offset, offset + blockSize)));
|
|
67
|
+
assert.ok(authorization.pending.length <= blockSize + 160);
|
|
68
|
+
}
|
|
69
|
+
blocks.push(authorization.push(Buffer.alloc(0), true));
|
|
70
|
+
return Buffer.concat(blocks).toString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The three lines from issue #249, which the old rules truncated.
|
|
74
|
+
const ISSUE_249_LINES = [
|
|
75
|
+
"Command: gate='/tmp/hive-mind-disk-baseline-0ce9e170-e6d3-494d-91e1-0d820245d34b'",
|
|
76
|
+
'🧠 Formal AI: endpoint http://172.18.0.2:8080/api/openai/v1',
|
|
77
|
+
'{"bodyPreview":"{\\"messages\\":[{\\"content\\":\\"use: curl -L -H \\\\\\"Authorization: token $(gh auth token)\\\\\\" -o <filename> <url>\\\\n...the rest of a 100 KB prompt...\\"}]}"}',
|
|
78
|
+
'proxy http://myproxy:3128 and http://127.0.0.1:34271/session',
|
|
79
|
+
'Authorization: [REDACTED] stays as it is',
|
|
80
|
+
'account konard@example.com https://github.com/konard',
|
|
81
|
+
];
|
|
82
|
+
|
|
13
83
|
function credentials() {
|
|
14
84
|
return [
|
|
15
85
|
...'pousr'.split('').map((kind) => `gh${kind}_${'a'.repeat(36)}`),
|
|
16
86
|
`github_` + `pat_${'b'.repeat(82)}`,
|
|
17
87
|
`sk-${'c'.repeat(48)}`,
|
|
18
88
|
`sk-` + `proj-${'d'.repeat(90)}`,
|
|
19
|
-
`sk-` + `ant-api03-${'e'.repeat(90)}`,
|
|
20
89
|
`AK` + `IA${'F'.repeat(16)}`,
|
|
21
90
|
`AS` + `IA${'G'.repeat(16)}`,
|
|
22
91
|
'ey' + 'JhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature',
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
92
|
+
'arbitrary-credential',
|
|
93
|
+
'encoded-credential',
|
|
94
|
+
'password',
|
|
95
|
+
'arbitrary-aws-secret',
|
|
27
96
|
'custom-secret-value',
|
|
28
|
-
`${Buffer.from(' \n{"alg":"HS256"}').toString('base64url')}.${Buffer.from('{"sub":"x"}').toString('base64url')}.signature`,
|
|
29
97
|
];
|
|
30
98
|
}
|
|
31
99
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
100
|
+
describe('log sanitization', () => {
|
|
101
|
+
it('keeps every line from issue #249 and public account details', () => {
|
|
102
|
+
const input = `${ISSUE_249_LINES.join('\n')}\n`;
|
|
103
|
+
assert.strictEqual(sanitizeFile(input).toString(), input);
|
|
35
104
|
});
|
|
36
|
-
const blocks = [];
|
|
37
|
-
for (let offset = 0; offset < input.length; offset += blockSize) {
|
|
38
|
-
blocks.push(sanitizer.push(input.subarray(offset, offset + blockSize)));
|
|
39
|
-
}
|
|
40
|
-
blocks.push(sanitizer.push(Buffer.alloc(0), true));
|
|
41
|
-
return Buffer.concat(blocks);
|
|
42
|
-
}
|
|
43
105
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
106
|
+
it('redacts credentials and credential-named environment values', () => {
|
|
107
|
+
const input = [
|
|
108
|
+
...credentials().slice(0, 9),
|
|
109
|
+
'Authorization: Bearer arbitrary-credential',
|
|
110
|
+
'Proxy-Authorization: Basic encoded-credential',
|
|
111
|
+
'clone https://user:password@example.test/repo.git',
|
|
112
|
+
'AWS_SECRET_ACCESS_KEY=arbitrary-aws-secret',
|
|
113
|
+
'value custom-secret-value end',
|
|
114
|
+
'ordinary utf8: café 😀',
|
|
115
|
+
'',
|
|
116
|
+
].join('\n');
|
|
117
|
+
const output = sanitizeFile(
|
|
118
|
+
input,
|
|
119
|
+
testEnv({ START_TEST_SECRET: 'custom-secret-value' })
|
|
120
|
+
).toString();
|
|
121
|
+
for (const [index, secret] of credentials().entries()) {
|
|
122
|
+
assert.ok(!output.includes(secret), `leaked secret #${index}`);
|
|
123
|
+
}
|
|
124
|
+
for (const kept of [
|
|
125
|
+
'Authorization: [REDACTED]\n',
|
|
126
|
+
'Proxy-Authorization: [REDACTED]\n',
|
|
127
|
+
'clone https://[REDACTED]@example.test/repo.git\n',
|
|
128
|
+
'value [REDACTED] end\n',
|
|
129
|
+
'ordinary utf8: café 😀\n',
|
|
130
|
+
]) {
|
|
131
|
+
assert.ok(output.includes(kept), `missing ${kept}`);
|
|
65
132
|
}
|
|
66
133
|
});
|
|
67
134
|
|
|
68
|
-
it('
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
]
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
it('masks only the Authorization scheme and token, keeping the rest of the line (#249)', () => {
|
|
136
|
+
const cases = [
|
|
137
|
+
[
|
|
138
|
+
`curl -H 'Authorization: Basic dXNlcjpwYXNz' -o out.txt url`,
|
|
139
|
+
`curl -H 'Authorization: [REDACTED]' -o out.txt url`,
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
'Authorization: Bearer abc rest of line',
|
|
143
|
+
'Authorization: [REDACTED] rest of line',
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
'Authorization: token abc, other: keep',
|
|
147
|
+
'Authorization: [REDACTED], other: keep',
|
|
148
|
+
],
|
|
149
|
+
[
|
|
150
|
+
'{"h":"Authorization: Bearer abc\\" tail","next":"keep"}',
|
|
151
|
+
'{"h":"Authorization: [REDACTED]\\" tail","next":"keep"}',
|
|
152
|
+
],
|
|
153
|
+
[
|
|
154
|
+
'Authorization: token $(gh auth token) and ${GITHUB_TOKEN}',
|
|
155
|
+
'Authorization: token $(gh auth token) and ${GITHUB_TOKEN}',
|
|
156
|
+
],
|
|
157
|
+
['Authorization: Bearer <token>', 'Authorization: Bearer <token>'],
|
|
158
|
+
];
|
|
159
|
+
const input = cases.map(([line]) => line).join('\n');
|
|
160
|
+
const expected = cases.map(([, line]) => line).join('\n');
|
|
161
|
+
for (const size of [1, 7, 31, 256, 65536]) {
|
|
162
|
+
assert.strictEqual(mask(Buffer.from(input), size), expected);
|
|
84
163
|
}
|
|
164
|
+
// The library itself masks the scheme before a placeholder; nothing is lost.
|
|
165
|
+
assert.strictEqual(
|
|
166
|
+
sanitizeFile(input).toString(),
|
|
167
|
+
expected.replace('Bearer <token>', '[REDACTED] <token>')
|
|
168
|
+
);
|
|
85
169
|
});
|
|
86
170
|
|
|
87
|
-
it('
|
|
88
|
-
const sanitizer = new StreamingSanitizer({});
|
|
171
|
+
it('masks long Authorization tokens across blocks with bounded memory', () => {
|
|
89
172
|
const input = Buffer.from(
|
|
90
|
-
`Authorization: Bearer
|
|
173
|
+
`Authorization: Bearer ${'a'.repeat(200000)} tail\nordinary\n`
|
|
91
174
|
);
|
|
92
|
-
const output = [];
|
|
93
|
-
for (let offset = 0; offset < input.length; offset += 65536) {
|
|
94
|
-
output.push(sanitizer.push(input.subarray(offset, offset + 65536)));
|
|
95
|
-
}
|
|
96
|
-
output.push(sanitizer.push(Buffer.alloc(0), true));
|
|
97
175
|
assert.strictEqual(
|
|
98
|
-
|
|
99
|
-
'[REDACTED]\nordinary\n'
|
|
176
|
+
mask(input, 65536),
|
|
177
|
+
'Authorization: [REDACTED] tail\nordinary\n'
|
|
178
|
+
);
|
|
179
|
+
assert.strictEqual(
|
|
180
|
+
sanitizeFile(input).toString(),
|
|
181
|
+
'Authorization: [REDACTED] tail\nordinary\n'
|
|
100
182
|
);
|
|
101
183
|
});
|
|
102
184
|
|
|
103
|
-
it('
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
)
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
assert.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
it('redacts multiline and short known secrets, preserving unrelated bytes', () => {
|
|
118
|
-
const sanitizer = new StreamingSanitizer({
|
|
119
|
-
GH_TOKEN: 'abc\nxyz',
|
|
120
|
-
TEST_PASSWORD: '!',
|
|
121
|
-
});
|
|
122
|
-
const output = sanitizer
|
|
123
|
-
.push(Buffer.from('safe abc\nxyz value !'), true)
|
|
124
|
-
.toString();
|
|
125
|
-
assert.strictEqual(output, 'safe [REDACTED] value [REDACTED]');
|
|
185
|
+
it('preserves invalid UTF-8 bytes and line endings', () => {
|
|
186
|
+
const input = Buffer.concat([
|
|
187
|
+
Buffer.from('bad '),
|
|
188
|
+
Buffer.from([0xff, 0xfe, 0xc3]),
|
|
189
|
+
Buffer.from(` gh${'p_'}${'x'.repeat(36)}\r\nnext\rend`),
|
|
190
|
+
]);
|
|
191
|
+
const expected = Buffer.concat([
|
|
192
|
+
Buffer.from('bad '),
|
|
193
|
+
Buffer.from([0xff, 0xfe, 0xc3]),
|
|
194
|
+
Buffer.from(' [REDACTED]\r\nnext\rend'),
|
|
195
|
+
]);
|
|
196
|
+
assert.deepStrictEqual(sanitizeFile(input), expected);
|
|
197
|
+
assert.deepStrictEqual(sanitizeFile(Buffer.alloc(0)), Buffer.alloc(0));
|
|
126
198
|
});
|
|
127
199
|
|
|
128
|
-
it('
|
|
129
|
-
const
|
|
130
|
-
GH_TOKEN: 'abcde',
|
|
131
|
-
TEST_PASSWORD: 'cdefg',
|
|
132
|
-
GITHUB_PAT: 'opaque-known-pat',
|
|
133
|
-
});
|
|
200
|
+
it('sanitizes issue text with the same rules', () => {
|
|
201
|
+
const token = `gh${'p_'}${'a'.repeat(36)}`;
|
|
134
202
|
assert.strictEqual(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
)
|
|
140
|
-
.toString(),
|
|
141
|
-
'[REDACTED] [REDACTED] example.test.invalid'
|
|
203
|
+
sanitizeText(`echo ${token} 😀 'Authorization: token abc' -o x`, {
|
|
204
|
+
env: testEnv(),
|
|
205
|
+
}),
|
|
206
|
+
`echo [REDACTED] 😀 'Authorization: [REDACTED]' -o x`
|
|
142
207
|
);
|
|
143
|
-
const short = new StreamingSanitizer({ GH_TOKEN: 'a' });
|
|
144
208
|
assert.strictEqual(
|
|
145
|
-
|
|
146
|
-
'
|
|
209
|
+
sanitizeText('echo "quoted" $(id) `hostname`', { env: testEnv() }),
|
|
210
|
+
'echo "quoted" $(id) `hostname`'
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('resolves the override first and the bundled library otherwise', () => {
|
|
215
|
+
assert.deepStrictEqual(
|
|
216
|
+
resolveSanitizerCommand({ START_SENSITIVE_DATA_SANITIZER: '/x/sds' }),
|
|
217
|
+
{ command: '/x/sds', args: [] }
|
|
218
|
+
);
|
|
219
|
+
const bundled = resolveSanitizerCommand({});
|
|
220
|
+
assert.strictEqual(bundled.command, process.execPath);
|
|
221
|
+
assert.ok(
|
|
222
|
+
bundled.args[0].endsWith(
|
|
223
|
+
path.join(
|
|
224
|
+
'sensitive-data-sanitizer',
|
|
225
|
+
'bin',
|
|
226
|
+
'sensitive-data-sanitizer.js'
|
|
227
|
+
)
|
|
228
|
+
)
|
|
147
229
|
);
|
|
148
230
|
});
|
|
149
231
|
|
|
150
232
|
it('creates a private sanitized copy and removes it without changing the source', () => {
|
|
151
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-test-'));
|
|
152
|
-
const source = path.join(dir, 'source.log');
|
|
153
233
|
const input = `gh` + `p_${'x'.repeat(36)}`;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const copy = sanitizeLogToTemp(source, { env: {} });
|
|
234
|
+
withLog(input, (source) => {
|
|
235
|
+
const copy = sanitizeLogToTemp(source, { env: testEnv() });
|
|
157
236
|
assert.notStrictEqual(copy.path, source);
|
|
158
237
|
assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), '[REDACTED]');
|
|
238
|
+
assert.deepStrictEqual(fs.readdirSync(path.dirname(copy.path)), [
|
|
239
|
+
'execution.log',
|
|
240
|
+
]);
|
|
159
241
|
if (process.platform !== 'win32') {
|
|
160
242
|
assert.strictEqual(fs.statSync(copy.path).mode & 0o777, 0o600);
|
|
161
243
|
assert.strictEqual(
|
|
@@ -165,23 +247,17 @@ describe('log sanitization', () => {
|
|
|
165
247
|
}
|
|
166
248
|
assert.strictEqual(fs.readFileSync(source, 'utf8'), input);
|
|
167
249
|
copy.cleanup();
|
|
168
|
-
assert.ok(!fs.existsSync(copy.path));
|
|
169
|
-
}
|
|
170
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
171
|
-
}
|
|
250
|
+
assert.ok(!fs.existsSync(path.dirname(copy.path)));
|
|
251
|
+
});
|
|
172
252
|
});
|
|
173
253
|
|
|
174
|
-
it('
|
|
254
|
+
it('keeps private permissions even with a permissive umask', () => {
|
|
175
255
|
if (process.platform === 'win32') {
|
|
176
256
|
return;
|
|
177
257
|
}
|
|
178
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-creation-'));
|
|
179
|
-
const source = path.join(dir, 'source.log');
|
|
180
|
-
fs.writeFileSync(source, 'ordinary output');
|
|
181
258
|
const originalOpen = fs.openSync;
|
|
182
259
|
const originalUmask = process.umask(0);
|
|
183
260
|
let creationChecked = false;
|
|
184
|
-
let copy;
|
|
185
261
|
fs.openSync = (...args) => {
|
|
186
262
|
if (args[1] !== 'wx') {
|
|
187
263
|
return originalOpen(...args);
|
|
@@ -196,26 +272,104 @@ describe('log sanitization', () => {
|
|
|
196
272
|
return descriptor;
|
|
197
273
|
};
|
|
198
274
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
275
|
+
withLog('ordinary output', (source) => {
|
|
276
|
+
const copy = sanitizeLogToTemp(source, { env: testEnv() });
|
|
277
|
+
try {
|
|
278
|
+
assert.ok(creationChecked);
|
|
279
|
+
assert.strictEqual(fs.statSync(copy.path).mode & 0o777, 0o600);
|
|
280
|
+
assert.strictEqual(
|
|
281
|
+
fs.readFileSync(copy.path, 'utf8'),
|
|
282
|
+
'ordinary output'
|
|
283
|
+
);
|
|
284
|
+
} finally {
|
|
285
|
+
copy.cleanup();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
202
288
|
} finally {
|
|
203
289
|
fs.openSync = originalOpen;
|
|
204
290
|
process.umask(originalUmask);
|
|
205
|
-
copy?.cleanup();
|
|
206
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
207
291
|
}
|
|
208
292
|
});
|
|
209
293
|
|
|
210
|
-
it('
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
294
|
+
it('reports scanned bytes and added redactions in verbose mode only', () => {
|
|
295
|
+
const messages = [];
|
|
296
|
+
const originalLog = console.log;
|
|
297
|
+
console.log = (message) => messages.push(message);
|
|
298
|
+
try {
|
|
299
|
+
withLog(`[REDACTED] gh${'p_'}${'x'.repeat(36)}\n`, (source) => {
|
|
300
|
+
sanitizeLogToTemp(source, { env: testEnv() }).cleanup();
|
|
301
|
+
sanitizeLogToTemp(source, { env: testEnv(), verbose: true }).cleanup();
|
|
302
|
+
});
|
|
303
|
+
} finally {
|
|
304
|
+
console.log = originalLog;
|
|
305
|
+
}
|
|
306
|
+
assert.deepStrictEqual(messages, [
|
|
307
|
+
'Prepared private sanitized log: 52 bytes scanned, 1 redactions.',
|
|
308
|
+
]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('fails closed with value-free codes and leaves no copy behind', () => {
|
|
312
|
+
const before = fs
|
|
313
|
+
.readdirSync(os.tmpdir())
|
|
314
|
+
.filter((name) => name.startsWith('start-sanitized-')).length;
|
|
315
|
+
const attempt = (source, env = testEnv()) => {
|
|
316
|
+
try {
|
|
317
|
+
sanitizeLogToTemp(source, { env }).cleanup();
|
|
318
|
+
} catch (error) {
|
|
319
|
+
assert.ok(error instanceof SanitizationError);
|
|
320
|
+
return error;
|
|
321
|
+
}
|
|
322
|
+
assert.fail('sanitization must fail');
|
|
323
|
+
};
|
|
324
|
+
assert.match(attempt('/does-not-exist').message, /\(ERR_SOURCE\)/);
|
|
325
|
+
assert.match(attempt(os.tmpdir()).message, /\(ERR_SOURCE\)/);
|
|
326
|
+
withLog('secret\0binary', (source, dir) => {
|
|
327
|
+
// The library rejects NUL bytes rather than guessing at binary content.
|
|
328
|
+
assert.strictEqual(attempt(source).code, 'ERR_BINARY');
|
|
329
|
+
assert.strictEqual(
|
|
330
|
+
attempt(
|
|
331
|
+
source,
|
|
332
|
+
testEnv({
|
|
333
|
+
START_SENSITIVE_DATA_SANITIZER: path.join(dir, 'missing'),
|
|
334
|
+
})
|
|
335
|
+
).code,
|
|
336
|
+
'ERR_UNAVAILABLE'
|
|
337
|
+
);
|
|
338
|
+
if (process.platform !== 'win32') {
|
|
339
|
+
const failing = path.join(dir, 'failing-sanitizer');
|
|
340
|
+
fs.writeFileSync(
|
|
341
|
+
failing,
|
|
342
|
+
'#!/bin/sh\necho "secret-from-stderr (ERR_WORKER)" >&2\nexit 2\n',
|
|
343
|
+
{ mode: 0o755 }
|
|
344
|
+
);
|
|
345
|
+
const error = attempt(
|
|
346
|
+
source,
|
|
347
|
+
testEnv({ START_SENSITIVE_DATA_SANITIZER: failing })
|
|
348
|
+
);
|
|
349
|
+
assert.strictEqual(
|
|
350
|
+
error.message,
|
|
351
|
+
'Log sanitization failed (ERR_WORKER); upload blocked.'
|
|
352
|
+
);
|
|
353
|
+
// A sanitizer that succeeds without writing the output blocks too.
|
|
354
|
+
fs.writeFileSync(failing, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
355
|
+
assert.strictEqual(
|
|
356
|
+
attempt(source, testEnv({ START_SENSITIVE_DATA_SANITIZER: failing }))
|
|
357
|
+
.code,
|
|
358
|
+
'ERR_OUTPUT'
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
215
362
|
assert.throws(
|
|
216
|
-
() =>
|
|
217
|
-
|
|
363
|
+
() =>
|
|
364
|
+
sanitizeText('x', {
|
|
365
|
+
env: testEnv({ START_SENSITIVE_DATA_SANITIZER: '/does-not-exist' }),
|
|
366
|
+
}),
|
|
367
|
+
/^Error: Text sanitization failed \(ERR_UNAVAILABLE\); publication blocked\.$/
|
|
218
368
|
);
|
|
369
|
+
const after = fs
|
|
370
|
+
.readdirSync(os.tmpdir())
|
|
371
|
+
.filter((name) => name.startsWith('start-sanitized-')).length;
|
|
372
|
+
assert.ok(after <= before);
|
|
219
373
|
});
|
|
220
374
|
|
|
221
375
|
it('blocks both upload paths on read or write failure and does not expose exception text', () => {
|
|
@@ -234,7 +388,12 @@ describe('log sanitization', () => {
|
|
|
234
388
|
throw new Error('sensitive error details');
|
|
235
389
|
};
|
|
236
390
|
try {
|
|
237
|
-
|
|
391
|
+
const result = uploadLogPath(source);
|
|
392
|
+
assert.strictEqual(result.success, false);
|
|
393
|
+
assert.strictEqual(
|
|
394
|
+
result.error,
|
|
395
|
+
'Log sanitization failed (ERR_SOURCE); upload blocked.'
|
|
396
|
+
);
|
|
238
397
|
assert.strictEqual(uploadLog(source), null);
|
|
239
398
|
} finally {
|
|
240
399
|
fs[method] = original;
|
|
@@ -245,6 +404,11 @@ describe('log sanitization', () => {
|
|
|
245
404
|
(message) => !message.includes('sensitive error details')
|
|
246
405
|
)
|
|
247
406
|
);
|
|
407
|
+
assert.ok(
|
|
408
|
+
messages.includes(
|
|
409
|
+
'Warning: Log sanitization failed (ERR_SOURCE); upload blocked.'
|
|
410
|
+
)
|
|
411
|
+
);
|
|
248
412
|
} finally {
|
|
249
413
|
console.log = originalLog;
|
|
250
414
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -257,7 +421,9 @@ describe('log sanitization', () => {
|
|
|
257
421
|
}
|
|
258
422
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-sanitize-test-'));
|
|
259
423
|
const log = path.join(dir, 'source.log');
|
|
260
|
-
|
|
424
|
+
const line =
|
|
425
|
+
"Command: gate='/tmp/hive-mind-disk-baseline-0ce9e170' http://172.18.0.2:8080/v1";
|
|
426
|
+
fs.writeFileSync(log, `${line} gh` + `p_${'x'.repeat(36)}`);
|
|
261
427
|
fs.writeFileSync(
|
|
262
428
|
path.join(dir, 'gh-upload-log'),
|
|
263
429
|
'#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE_ARGS"\ncat "$1" > "$CAPTURE_LOG"\nls -ld "$1" | cut -c1-10 > "$CAPTURE_MODE"\necho https://gist.github.com/test/id\n',
|
|
@@ -270,15 +436,14 @@ describe('log sanitization', () => {
|
|
|
270
436
|
? `require(${JSON.stringify(require.resolve('../src/lib/failure-handler'))}).uploadLog(process.env.SOURCE_LOG)`
|
|
271
437
|
: `require(${JSON.stringify(require.resolve('../src/lib/log-uploader'))}).uploadLogPath(process.env.SOURCE_LOG, { noSanitize: ${kind === 'optout'} })`;
|
|
272
438
|
execFileSync(process.execPath, ['-e', script], {
|
|
273
|
-
env: {
|
|
274
|
-
...process.env,
|
|
439
|
+
env: testEnv({
|
|
275
440
|
PATH: `${dir}${path.delimiter}${process.env.PATH}`,
|
|
276
441
|
SOURCE_LOG: log,
|
|
277
442
|
CAPTURE_ARGS: path.join(dir, 'args'),
|
|
278
443
|
CAPTURE_LOG: path.join(dir, 'uploaded'),
|
|
279
444
|
CAPTURE_MODE: path.join(dir, 'mode'),
|
|
280
445
|
GH_UPLOAD_PUBLIC: 'true',
|
|
281
|
-
},
|
|
446
|
+
}),
|
|
282
447
|
stdio: 'pipe',
|
|
283
448
|
});
|
|
284
449
|
const args = fs
|
|
@@ -288,7 +453,9 @@ describe('log sanitization', () => {
|
|
|
288
453
|
assert.strictEqual(args[1], '--private');
|
|
289
454
|
assert.strictEqual(
|
|
290
455
|
fs.readFileSync(path.join(dir, 'uploaded'), 'utf8'),
|
|
291
|
-
kind === 'optout'
|
|
456
|
+
kind === 'optout'
|
|
457
|
+
? fs.readFileSync(log, 'utf8')
|
|
458
|
+
: `${line} [REDACTED]`
|
|
292
459
|
);
|
|
293
460
|
if (kind !== 'optout') {
|
|
294
461
|
assert.notStrictEqual(args[0], log);
|