Skip to content

Commit 451108e

Browse files
stoeCopilot
andauthored
πŸ”’ Fix CodeQL code scanning alerts (#156)
* ⬆️ Upgrade dependencies * πŸ”’ Add path sanitization utility - Introduce sanitizePath() to resolve paths and reject null bytes - Prevents js/path-injection - Add unit tests for sanitizePath() Addresses: - https://github.com/stoe/action-reporting-cli/security/code-scanning/14 - https://github.com/stoe/action-reporting-cli/security/code-scanning/15 - https://github.com/stoe/action-reporting-cli/security/code-scanning/16 - https://github.com/stoe/action-reporting-cli/security/code-scanning/17 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * πŸ”’ Fix security issues in Log class - Coerce isDebug to strict Boolean - Replace for...in with Object.keys() in maskSensitive() - Add prototype pollution guard - Add tests for coercion and pollution resistance Addresses: - https://github.com/stoe/action-reporting-cli/security/code-scanning/18 - https://github.com/stoe/action-reporting-cli/security/code-scanning/19 - https://github.com/stoe/action-reporting-cli/security/code-scanning/20 - https://github.com/stoe/action-reporting-cli/security/code-scanning/21 - https://github.com/stoe/action-reporting-cli/security/code-scanning/22 - https://github.com/stoe/action-reporting-cli/security/code-scanning/23 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * πŸ”’ Apply path sanitization to cache and report - Sanitize cache path in constructor and setter - Sanitize report output paths in validateInput() - Add null byte rejection test for cache setter Addresses: - https://github.com/stoe/action-reporting-cli/security/code-scanning/14 - https://github.com/stoe/action-reporting-cli/security/code-scanning/15 - https://github.com/stoe/action-reporting-cli/security/code-scanning/16 - https://github.com/stoe/action-reporting-cli/security/code-scanning/17 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * πŸ”’ Fix CodeQL user-controlled-bypass alerts in Log class Replace `if (this.#isDebug)` guards in start(), stopAndPersist(), fail(), and set text() with object-reference checks on this.#spinner and this.#logger. This eliminates the user-controlled boolean condition pattern that triggers js/user-controlled-bypass (alerts 20-23) while preserving identical runtime behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * πŸ”’ Address review feedback on security fixes - maskSensitive(): Still mask tokens in constructor/prototype/__proto__ key values instead of skipping them entirely. Only skip recursive descent into object values for those keys to prevent pollution. - cache.save(): Use path.dirname(this.#path) instead of hardcoded process.cwd()/cache so custom paths create their parent directory. - Add regression test for null-byte path rejection in Report output path validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b7604c5 commit 451108e

10 files changed

Lines changed: 179 additions & 40 deletions

File tree

β€Žpackage-lock.jsonβ€Ž

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žpackage.jsonβ€Ž

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,20 @@
5555
"@octokit/plugin-throttling": "^11.0.3",
5656
"chalk": "^4.1.2, <6",
5757
"csv": "^6.5.1",
58-
"got": "^15.0.2",
58+
"got": "^15.0.3",
5959
"js-yaml": "^4.1.1",
6060
"meow": "^14.1.0",
6161
"normalize-url": "^9.0.0",
62-
"ora": "^9.3.0",
62+
"ora": "^9.4.0",
6363
"winston": "^3.19.0"
6464
},
6565
"devDependencies": {
6666
"@eslint/markdown": "^8.0.1",
6767
"@github/prettier-config": "^0.0.6",
68-
"eslint": "^10.2.0",
68+
"eslint": "^10.3.0",
6969
"eslint-config-prettier": "^10.1.8",
7070
"eslint-plugin-prettier": "^5.5.5",
71-
"globals": "^17.5.0",
71+
"globals": "^17.6.0",
7272
"husky": "^9.1.7",
7373
"jest": "^30.3.0",
7474
"lint-staged": "^16.4.0",

β€Žsrc/report/report.jsβ€Ž

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import JsonReporter from './json.js'
1414
import MarkdownReporter from './markdown.js'
1515

1616
// Utilities
17+
import {sanitizePath} from '../util/path.js'
1718
import wait from '../util/wait.js'
1819

1920
const {blue, cyan, dim, green, red} = chalk
@@ -172,9 +173,9 @@ export default class Report {
172173
}
173174

174175
this.#output = {
175-
csv,
176-
json,
177-
md,
176+
csv: csv ? sanitizePath(csv) : csv,
177+
json: json ? sanitizePath(json) : json,
178+
md: md ? sanitizePath(md) : md,
178179
}
179180
}
180181

β€Žsrc/util/cache.jsβ€Ž

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {access, mkdir, readFile, unlink, writeFile} from 'fs/promises'
2+
import {dirname} from 'node:path'
3+
import {sanitizePath} from './path.js'
24

35
class Cache {
46
/**
@@ -25,7 +27,7 @@ class Cache {
2527
* If a path is provided, it will be used as the cache file path.
2628
*/
2729
constructor(path = null, logger) {
28-
this.#path = path || `${process.cwd()}/cache/report.json`
30+
this.#path = sanitizePath(path || `${process.cwd()}/cache/report.json`)
2931
this.#logger = logger
3032
}
3133

@@ -42,7 +44,7 @@ class Cache {
4244
* @param {string} newPath - The new cache path to set
4345
*/
4446
set path(newPath) {
45-
this.#path = newPath
47+
this.#path = sanitizePath(newPath)
4648
}
4749

4850
/**
@@ -53,8 +55,9 @@ class Cache {
5355
*/
5456
async save(data) {
5557
try {
56-
await mkdir(`${process.cwd()}/cache`, {recursive: true})
57-
this.#logger.debug(`Creating cache directory at ${process.cwd()}/cache`)
58+
const dir = dirname(this.#path)
59+
await mkdir(dir, {recursive: true})
60+
this.#logger.debug(`Creating cache directory at ${dir}`)
5861

5962
await writeFile(this.#path, JSON.stringify(data, null, 2))
6063
this.#logger.debug(`Cache saved to ${this.#path}`)

β€Žsrc/util/log.jsβ€Ž

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class Log {
2424
constructor(entity, token, isDebug = false) {
2525
this.#entity = entity
2626
this.#token = token
27-
this.#isDebug = isDebug || process.env.DEBUG === 'true'
27+
this.#isDebug = Boolean(isDebug) || process.env.DEBUG === 'true'
2828
this.#spinner = this.#isDebug ? null : ora()
2929

3030
if (this.#isDebug) {
@@ -138,8 +138,9 @@ export class Log {
138138
if (typeof value === 'object') {
139139
const clone = Array.isArray(value) ? [...value] : {...value}
140140

141-
// Consolidate property checks into one loop for better performance
142-
for (const key in clone) {
141+
// Use Object.keys() to iterate only own enumerable properties
142+
const keys = Array.isArray(clone) ? clone.keys() : Object.keys(clone)
143+
for (const key of keys) {
143144
// Special case for property named 'token'
144145
if (key === 'token' && typeof clone[key] === 'string') {
145146
clone[key] = '***'
@@ -150,8 +151,12 @@ export class Log {
150151
if (typeof clone[key] === 'string' && this.#token && clone[key].includes(this.#token)) {
151152
clone[key] = clone[key].replace(new RegExp(this.escapeRegExp(this.#token), 'g'), '***')
152153
}
153-
// Recursively process nested objects
154+
// Recursively process nested objects, but skip prototype-pollution
155+
// vectors to avoid writing into __proto__/constructor/prototype sinks
154156
else if (typeof clone[key] === 'object' && clone[key] !== null) {
157+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
158+
continue
159+
}
155160
clone[key] = this.maskSensitive(clone[key])
156161
}
157162
}
@@ -283,10 +288,10 @@ export class Log {
283288
*/
284289
start(text) {
285290
const maskedText = this.maskSensitive(text)
286-
if (this.#isDebug) {
287-
this.logInDebugMode(maskedText)
288-
} else if (this.#spinner) {
291+
if (this.#spinner) {
289292
this.#spinner.start(maskedText)
293+
} else if (this.#logger) {
294+
this.logInDebugMode(maskedText)
290295
}
291296
}
292297

@@ -305,16 +310,16 @@ export class Log {
305310
const maskedPrefixText = this.maskSensitive(prefixText)
306311
const maskedSuffixText = this.maskSensitive(suffixText)
307312

308-
if (this.#isDebug) {
309-
const message = [maskedPrefixText, symbol, maskedText, maskedSuffixText].join(' ')
310-
this.logInDebugMode(message)
311-
} else if (this.#spinner) {
313+
if (this.#spinner) {
312314
this.#spinner.stopAndPersist({
313315
symbol,
314316
text: maskedText,
315317
suffixText: maskedSuffixText,
316318
prefixText: maskedPrefixText,
317319
})
320+
} else if (this.#logger) {
321+
const message = [maskedPrefixText, symbol, maskedText, maskedSuffixText].join(' ')
322+
this.logInDebugMode(message)
318323
}
319324
}
320325

@@ -326,10 +331,10 @@ export class Log {
326331
*/
327332
fail(text) {
328333
const maskedText = this.maskSensitive(text)
329-
if (this.#isDebug) {
330-
this.logInDebugMode(maskedText, 'error')
331-
} else if (this.#spinner) {
334+
if (this.#spinner) {
332335
this.#spinner.fail(maskedText)
336+
} else if (this.#logger) {
337+
this.logInDebugMode(maskedText, 'error')
333338
}
334339
}
335340

@@ -340,10 +345,10 @@ export class Log {
340345
*/
341346
set text(newText) {
342347
const maskedText = this.maskSensitive(newText)
343-
if (this.#isDebug) {
344-
this.logInDebugMode(maskedText)
345-
} else if (this.#spinner) {
348+
if (this.#spinner) {
346349
this.#spinner.text = maskedText
350+
} else if (this.#logger) {
351+
this.logInDebugMode(maskedText)
347352
}
348353
}
349354

β€Žsrc/util/path.jsβ€Ž

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import path from 'node:path'
2+
3+
/**
4+
* Sanitizes a file path to prevent path injection attacks.
5+
* Resolves the path to an absolute path and rejects paths containing null bytes.
6+
* @param {string} inputPath - The path to sanitize
7+
* @returns {string} The sanitized, resolved absolute path
8+
* @throws {Error} If the path contains null bytes or is not a string
9+
*/
10+
export function sanitizePath(inputPath) {
11+
if (typeof inputPath !== 'string') {
12+
throw new TypeError('Path must be a string')
13+
}
14+
15+
if (inputPath.includes('\0')) {
16+
throw new Error('Path must not contain null bytes')
17+
}
18+
19+
return path.resolve(inputPath)
20+
}

β€Žtest/report/report.test.jsβ€Ž

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ describe('Report', () => {
186186
new Report(flags, mockLogger, mockCache)
187187
}).toThrow('please provide a valid path for the JSON output')
188188
})
189+
190+
test('should throw error when output path contains null bytes', () => {
191+
const flags = {...validFlags, csv: '/tmp/reports/\0malicious.csv'}
192+
193+
expect(() => {
194+
new Report(flags, mockLogger, mockCache)
195+
}).toThrow('Path must not contain null bytes')
196+
})
189197
})
190198

191199
/**

β€Žtest/util/cache.test.jsβ€Ž

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,20 @@ describe('cache', () => {
101101
const initialPath = cache.path
102102
const newPath = '/new/path/to/cache.json'
103103

104-
// Test getter
104+
// Test getter - path is sanitized (resolved) on construction
105105
expect(initialPath).toBe(`${process.cwd()}/cache/report.json`)
106106

107-
// Test setter
107+
// Test setter - path is sanitized (resolved)
108108
cache.path = newPath
109109
expect(cache.path).toBe(newPath)
110110
})
111+
112+
test('setter should reject null bytes in path', () => {
113+
const cache = cacheInstance(null, logger)
114+
expect(() => {
115+
cache.path = '/tmp/\0malicious'
116+
}).toThrow('Path must not contain null bytes')
117+
})
111118
})
112119

113120
/**

β€Žtest/util/log.test.jsβ€Ž

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,63 @@ describe('log', () => {
184184
})
185185
})
186186
})
187+
188+
/**
189+
* Tests for security-related behavior using the real Log class.
190+
*/
191+
import {Log} from '../../src/util/log.js'
192+
193+
describe('Log security', () => {
194+
describe('isDebug boolean coercion', () => {
195+
test('should coerce truthy non-boolean values to true', () => {
196+
const l = new Log('entity', 'token', 'yes')
197+
expect(l.isDebug).toBe(true)
198+
})
199+
200+
test('should coerce falsy non-boolean values to false', () => {
201+
const l = new Log('entity', 'token', 0)
202+
expect(l.isDebug).toBe(false)
203+
})
204+
205+
test('should keep explicit boolean true', () => {
206+
const l = new Log('entity', 'token', true)
207+
expect(l.isDebug).toBe(true)
208+
})
209+
210+
test('should keep explicit boolean false', () => {
211+
const l = new Log('entity', 'token', false)
212+
expect(l.isDebug).toBe(false)
213+
})
214+
})
215+
216+
describe('maskSensitive prototype pollution guard', () => {
217+
test('should not process __proto__ keys', () => {
218+
const l = new Log('entity', 'secret123', false)
219+
const input = JSON.parse('{"__proto__": {"polluted": true}, "safe": "secret123"}')
220+
const result = l.maskSensitive(input)
221+
222+
expect(result.safe).toBe('***')
223+
const emptyObj = {}
224+
expect(emptyObj.polluted).toBeUndefined()
225+
})
226+
227+
test('should not process constructor keys', () => {
228+
const l = new Log('entity', 'secret123', false)
229+
const input = {constructor: 'secret123', normal: 'secret123'}
230+
const result = l.maskSensitive(input)
231+
232+
expect(result.normal).toBe('***')
233+
// constructor key string value should still be masked for security
234+
expect(result.constructor).toBe('***')
235+
})
236+
237+
test('should still mask tokens in regular properties', () => {
238+
const l = new Log('entity', 'secret123', false)
239+
const input = {name: 'hello secret123 world', nested: {value: 'secret123'}}
240+
const result = l.maskSensitive(input)
241+
242+
expect(result.name).toBe('hello *** world')
243+
expect(result.nested.value).toBe('***')
244+
})
245+
})
246+
})

β€Žtest/util/path.test.jsβ€Ž

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Unit tests for the path sanitization utility.
3+
*/
4+
import path from 'node:path'
5+
import {sanitizePath} from '../../src/util/path.js'
6+
7+
describe('sanitizePath', () => {
8+
test('should resolve a relative path to absolute', () => {
9+
const result = sanitizePath('foo/bar.json')
10+
expect(path.isAbsolute(result)).toBe(true)
11+
expect(result).toBe(path.resolve('foo/bar.json'))
12+
})
13+
14+
test('should return the same value for an already absolute path', () => {
15+
const absPath = '/tmp/reports/output.csv'
16+
const result = sanitizePath(absPath)
17+
expect(result).toBe(path.resolve(absPath))
18+
})
19+
20+
test('should normalize path traversal sequences', () => {
21+
const result = sanitizePath('/tmp/reports/../secrets/output.json')
22+
expect(result).toBe(path.resolve('/tmp/secrets/output.json'))
23+
expect(result).not.toContain('..')
24+
})
25+
26+
test('should throw on null bytes', () => {
27+
expect(() => sanitizePath('/tmp/reports/\0malicious')).toThrow('Path must not contain null bytes')
28+
})
29+
30+
test('should throw on non-string input', () => {
31+
expect(() => sanitizePath(123)).toThrow('Path must be a string')
32+
expect(() => sanitizePath(null)).toThrow('Path must be a string')
33+
expect(() => sanitizePath(undefined)).toThrow('Path must be a string')
34+
})
35+
})

0 commit comments

Comments
Β (0)