Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/index.js

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
findUnexpectedGitlinks,
log,
matchGitArgs,
neutralizeLogString,
parseInputArray,
pickGitIdentityConfig,
} from './util';
Expand Down Expand Up @@ -121,7 +122,9 @@ core.info(`Running in ${baseDir}`);
throw new Error(
`There are ${
status.conflicted.length
} conflicting files: ${status.conflicted.join(', ')}`,
} conflicting files: ${status.conflicted
.map(neutralizeLogString)
.join(', ')}`,
);
} else core.info('> Not pulling from repo.');

Expand Down
79 changes: 77 additions & 2 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,85 @@ export async function getUserInfo(username?: string) {
};
}

/**
* Characters that can spoof or inject into CI logs when printed raw:
* C0/C1 controls + DEL, and Unicode bidi/isolate format controls
* (Trojan Source class: RLO/LRO/PDF/RLE/LRE/RLI/LRI/FSI/PDI, LRM/RLM, ALM).
*/
const LOG_UNSAFE_CHARS =
// eslint-disable-next-line no-control-regex
/[\u0000-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;

/**
* Replaces log-unsafe characters with visible `\uXXXX` escapes so bidi
* reordering and control-char injection cannot alter how paths render in logs.
*/
export function neutralizeLogString(s: string): string {
return s.replace(LOG_UNSAFE_CHARS, ch => {
const hex = ch.codePointAt(0)!.toString(16).padStart(4, '0');
return `\\u${hex}`;
});
}

const CIRCULAR_LOG_MARKER = '[Circular]';

/**
* Recursively neutralizes strings in values passed to log sinks (StatusSummary,
* commit results, Error messages, etc.).
* Tracks visited arrays/objects so circular references become a marker instead
* of overflowing the stack.
*/
export function neutralizeForLog(
value: unknown,
seen: WeakSet<object> = new WeakSet(),
): unknown {
if (typeof value === 'string') return neutralizeLogString(value);
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
) {
return value;
}
if (value instanceof Error) {
const sanitized = new Error(neutralizeLogString(value.message));
sanitized.name = neutralizeLogString(value.name);
if (value.stack) {
sanitized.stack = neutralizeLogString(value.stack);
}
return sanitized;
}
if (Array.isArray(value)) {
if (seen.has(value)) return CIRCULAR_LOG_MARKER;
seen.add(value);
return value.map(item => neutralizeForLog(item, seen));
}
if (typeof value === 'object') {
if (seen.has(value)) return CIRCULAR_LOG_MARKER;
seen.add(value);
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(
value as Record<string, unknown>,
)) {
out[neutralizeLogString(key)] = neutralizeForLog(nested, seen);
}
return out;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return value;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function log(err: any, data?: any) {
if (data) console.log(data);
if (err) core.error(err);
if (data) console.log(neutralizeForLog(data));
if (err) {
const sanitized = neutralizeForLog(err);
if (typeof sanitized === 'string' || sanitized instanceof Error) {
core.error(sanitized);
} else {
core.error(String(sanitized));
}
}
}

/** Git identity keys this action sets; safe to log (no credentials). */
Expand Down
69 changes: 69 additions & 0 deletions test/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
assertValidBranchName,
findUnexpectedGitlinks,
matchGitArgs,
neutralizeForLog,
neutralizeLogString,
parseInputArray,
pickGitIdentityConfig,
} from '../src/util';
Expand Down Expand Up @@ -376,3 +378,70 @@ describe('assertNoUnexpectedGitlinks', () => {
);
});
});

describe('neutralizeLogString', () => {
it('leaves normal filenames unchanged', () => {
expect(neutralizeLogString('src/main.ts')).toBe('src/main.ts');
expect(neutralizeLogString('docs/README.md')).toBe('docs/README.md');
});

it('escapes RLO and related bidi controls', () => {
const rlo = '\u202E';
const lro = '\u202D';
const rli = '\u2067';
expect(neutralizeLogString(`doc${rlo}txt.exe`)).toBe('doc\\u202etxt.exe');
expect(neutralizeLogString(`a${lro}b${rli}c`)).toBe('a\\u202db\\u2067c');
});

it('escapes newlines and other C0 controls', () => {
expect(neutralizeLogString('line1\nline2')).toBe('line1\\u000aline2');
expect(neutralizeLogString('a\rb')).toBe('a\\u000db');
});
});

describe('neutralizeForLog', () => {
it('recursively sanitizes StatusSummary-shaped objects', () => {
const rlo = '\u202E';
const spoofed = `doc${rlo}txt.exe`;
const input = {
conflicted: [],
created: [spoofed],
modified: ['ok.txt', spoofed],
files: [{path: spoofed, index: 'A', working_dir: ' '}],
not_added: 1,
isClean: () => true,
};
const result = neutralizeForLog(input) as typeof input;
expect(result.created).toStrictEqual(['doc\\u202etxt.exe']);
expect(result.modified).toStrictEqual(['ok.txt', 'doc\\u202etxt.exe']);
expect(result.files[0].path).toBe('doc\\u202etxt.exe');
expect(result.not_added).toBe(1);
});

it('sanitizes Error messages', () => {
const err = new Error('bad: file\u202Ename');
const result = neutralizeForLog(err) as Error;
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('bad: file\\u202ename');
});

it('passes through nullish and primitives', () => {
expect(neutralizeForLog(null)).toBeNull();
expect(neutralizeForLog(undefined)).toBeUndefined();
expect(neutralizeForLog(42)).toBe(42);
expect(neutralizeForLog(true)).toBe(true);
});

it('returns a marker for circular references', () => {
const obj: Record<string, unknown> = {path: 'ok.txt'};
obj.self = obj;
const arr: unknown[] = ['a'];
arr.push(arr);

expect(neutralizeForLog(obj)).toStrictEqual({
path: 'ok.txt',
self: '[Circular]',
});
expect(neutralizeForLog(arr)).toStrictEqual(['a', '[Circular]']);
});
});