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
41 changes: 30 additions & 11 deletions shared/packages/worker/src/worker/accessorHandlers/ftp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,15 @@ export class FTPAccessorHandle<Metadata> extends GenericAccessorHandle<Metadata>
} else throw new Error(`getPackageActualVersion: ${response.reason.user}: ${response.reason.tech}`)
}
async ensurePackageFulfilled(): Promise<void> {
await this.fileHandler.clearPackageRemoval(this.filePath)
if ((this.workOptions.removeDelay ?? -1) >= 0) {
// Only handle this if there is a removeDelay set, otherwise we can skip it to save time:
await this.fileHandler.clearPackageRemoval(this.filePath)
}
Comment on lines +201 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -i 'GenericFileOperations*' shared/packages/worker/src/worker/accessorHandlers | while read -r f; do
  echo "== $f =="
  rg -n -C4 '\b(clearPackageRemoval|handleRemovePackage|removeDuePackages)\b' "$f"
done

echo "== call sites =="
rg -n -C3 '\bclearPackageRemoval\s*\(' shared/packages/worker/src/worker/accessorHandlers
rg -n -C3 '\bhandleRemovePackage\s*\(' shared/packages/worker/src/worker/accessorHandlers

Repository: Sofie-Automation/sofie-package-manager

Length of output: 16656


Remove the guard or apply consistently across all file handlers.

The clearPackageRemoval() guard at lines 201-204 and 272-275 creates a behavioral inconsistency. All other handlers (s3, localFolder, httpProxy, fileShare) call clearPackageRemoval() unconditionally in equivalent methods. When FTP skips this due to removeDelay < 0, any stale delayed-removal entry persists in the packagesToRemove list (see GenericFileOperations.ts:41-45) and can still execute later via removeDuePackages(). If a package is scheduled for removal, then re-written with removeDelay = undefined, the stale removal entry will not be canceled. Either remove the guard to match other handlers or apply it to all handlers consistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/packages/worker/src/worker/accessorHandlers/ftp.ts` around lines 201 -
204, The FTP handler conditionally skips calling fileHandler.clearPackageRemoval
when this.workOptions.removeDelay is negative, which leaves stale entries in
packagesToRemove; remove the guard so clearPackageRemoval(this.filePath) is
called unconditionally (matching other handlers like
s3/localFolder/httpProxy/fileShare), or if you intentionally want conditional
behavior apply the same guard across all handlers; update the FTP methods that
currently wrap clearPackageRemoval with the (this.workOptions.removeDelay ?? -1)
>= 0 check to instead always call fileHandler.clearPackageRemoval(this.filePath)
(or make the same guard change in the other handler implementations) so
scheduled removals are correctly cancelled when a package is rewritten.

}
async removePackage(reason: string): Promise<void> {
await this.fileHandler.handleRemovePackage(this.filePath, this.packageName, reason)
}
async getPackageReadStream(): Promise<PackageReadStream> {
// important that this is a 'read', so that it doesn't go into a deadlock with putPackageStream() in case of an upload/download to the same accessorPackageContainer
const ftp = await this.prepareFTPClient()

const response = await ftp.download(this.fullPath)
Expand Down Expand Up @@ -267,7 +269,10 @@ export class FTPAccessorHandle<Metadata> extends GenericAccessorHandle<Metadata>
operationName: string,
source: string | GenericAccessorHandle<any>
): Promise<PackageOperation> {
await this.fileHandler.clearPackageRemoval(this.filePath)
if ((this.workOptions.removeDelay ?? -1) >= 0) {
// Only handle this if there is a removeDelay set, otherwise we can skip it to save time:
await this.fileHandler.clearPackageRemoval(this.filePath)
}
return this.logWorkOperation(operationName, source, this.packageName)
}
async finalizePackage(operation: PackageOperation): Promise<void> {
Expand Down Expand Up @@ -482,18 +487,26 @@ export class FTPAccessorHandle<Metadata> extends GenericAccessorHandle<Metadata>
[accessorType: string]: CachedClients | undefined
}

const cacheKey = JSON.stringify([
this.accessorId,
ftpOptions.serverType,
ftpOptions.host,
ftpOptions.port,
this.accessor.basePath ?? '/',
])
const cacheKey = JSON.stringify([this.accessorId, options, this.accessor.basePath ?? '/'])

let cachedClients = accessorCache[cacheKey]
if (cachedClients) {
// Check that options matches:
if (!isEqual(cachedClients.options, options)) {
// It the options don't match, something is wrong with the cacheKey

this.worker.logger.error(
`Something is wrong with the FTP client cacheKey. The options do not match the cacheKey. Deleting cached clients for this key. cacheKey: ${cacheKey}, options: ${JSON.stringify(
{
options,
password: '***',
}
)}, cachedOptions: ${JSON.stringify({
...cachedClients.options,
password: '***',
})}`
)
Comment on lines +496 to +508

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging FTP credentials in cache-mismatch error.

cacheKey, options, and cachedClients.options all serialize the FTPOptions object that includes username and password (constructed at lines 476‑484). Emitting these via logger.error writes the plaintext password to logs/log shipping. The same problem exists at the new info log on line 542 (Creating new FTP client for purpose=...). The file already establishes a redaction convention via the safeUrl getter (line 456), so logs should use a sanitized view of the options.

🔒 Proposed fix — redact password before logging (apply to both call sites)
+		const safeOptions = { ...options, password: options.password ? '***' : options.password }
+		const safeCacheKey = JSON.stringify([this.accessorId, safeOptions, this.accessor.basePath ?? '/'])
+
 		let cachedClients = accessorCache[cacheKey]
 		if (cachedClients) {
 			// Check that options matches:
 			if (!isEqual(cachedClients.options, options)) {
-				// It the options don't match, something is wrong with the cacheKey
-
-				this.worker.logger.error(
-					`Something is wrong with the FTP client cacheKey. The options do not match the cacheKey. Deleting cached clients for this key. cacheKey: ${cacheKey}, options: ${JSON.stringify(
-						options
-					)}, cachedOptions: ${JSON.stringify(cachedClients.options)}`
-				)
+				// If the options don't match, something is wrong with the cacheKey
+				const safeCachedOptions = {
+					...cachedClients.options,
+					password: cachedClients.options.password ? '***' : cachedClients.options.password,
+				}
+				this.worker.logger.error(
+					`Something is wrong with the FTP client cacheKey. The options do not match the cacheKey. Deleting cached clients for this key. cacheKey: ${safeCacheKey}, options: ${JSON.stringify(
+						safeOptions
+					)}, cachedOptions: ${JSON.stringify(safeCachedOptions)}`
+				)

And at line 542:

-			this.worker.logger.info(`Creating new FTP client for purpose="${purpose}", ${JSON.stringify(options)}`)
+			this.worker.logger.info(`Creating new FTP client for purpose="${purpose}", ${JSON.stringify(safeOptions)}`)

Also consider fixing the typo "It the options" → "If the options" on line 496.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/packages/worker/src/worker/accessorHandlers/ftp.ts` around lines 496 -
502, Log messages leak FTP credentials by serializing FTPOptions (cacheKey,
options, cachedClients.options) and in the creation info log; instead, before
calling this.worker.logger.error or .info, build a sanitized representation that
omits or masks sensitive fields (use the existing safeUrl getter or create a
small sanitizer that copies options and replaces password/username with masked
values) and log that sanitized object/URL; update both the cache-mismatch log
(currently referencing cacheKey/options/cachedClients.options) and the "Creating
new FTP client..." info log to use the sanitized view, and fix the typo "It the
options" → "If the options".


for (const c of cachedClients.clients) {
await c.client.destroy()
}
Expand Down Expand Up @@ -532,9 +545,10 @@ export class FTPAccessorHandle<Metadata> extends GenericAccessorHandle<Metadata>
}

if (!cachedClient) {
this.worker.logger.info(`Creating new FTP client for purpose="${purpose}", ${JSON.stringify(options)}`)
// Set up a new FTP client:
cachedClient = {
client: createFTPClient(ftpOptions.serverType, this.worker.logger, options),
client: createFTPClient(options.serverType, this.worker.logger, options),
purpose: purpose,
}
cachedClients.clients.push(cachedClient)
Expand Down Expand Up @@ -580,6 +594,11 @@ export class FTPAccessorHandle<Metadata> extends GenericAccessorHandle<Metadata>
return ftp.downloadContent(fullPath)
}
async readFileIfExists(fullPath: string): Promise<Buffer | undefined> {
// On FTP, it is a much lighter operation to check if the file exists that reading it,
// so we'll do that first:
const exists = await this.fileExists(fullPath)
if (!exists) return undefined

try {
return await this.readFile(fullPath)
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

/** A FTP Client that supports FTP & FTPS (FTP over TLS) connections. */
export class FTPClient extends FTPClientBase {
private client: FTP.Client
private client: SafeFTPClient

private initializing: Promise<void> | null = null
public destroyed = false
Expand All @@ -23,49 +23,8 @@

this.client = this.createFTPClient()
}
private createFTPClient(): FTP.Client {
const ftpClient = new FTP.Client()

// Because the FTP.Client methods needs to be called ONLY one at a time,
// we do a hack to override its methods with a promise queue:
const methodsToOverride: (keyof FTP.Client)[] = [
'access',
// 'close',
// 'closed',
'downloadTo',
'ensureDir',
'lastMod',
'list',
'remove',
'removeDir',
'rename',
'size',
// 'trackProgress',
'uploadFrom',
]

const queue = new PQueue({ concurrency: 1 })

for (const method of methodsToOverride) {
const orgMethod = (ftpClient as any)[method] as (...args: any[]) => Promise<any>
const newMethod = async (...args: any[]) => {
return queue.add(async () => {
const orgError = new Error(`Error executing ${method}: ${JSON.stringify(args)}`) // Used later
try {
const result = await orgMethod.apply(ftpClient, args)
return result
} catch (e) {
if (typeof e === 'object' && e !== null && 'stack' in e) {
e.stack += `\nOriginal stack: ${orgError.stack}`
}
throw e
}
})
}
;(ftpClient as any)[method] = newMethod
}

return ftpClient
private createFTPClient(): SafeFTPClient {
return new SafeFTPClient()
}

async init(): Promise<void> {
Expand All @@ -92,8 +51,10 @@
rejectUnauthorized: !this.options.allowAnyCertificate, // Allow self-signed certificates
},
}
await this.client.access(options)
await this.client.cd('/')
await this.client.batch(async (ftpClient) => {
await ftpClient.access(options)
await ftpClient.cd('/')
})

this.initializing = null // Reset initializing promise after successful initialization
})
Expand Down Expand Up @@ -173,61 +134,68 @@
packageExists: false,
}

const size = await this.client.size(fullPath)
return this.client.batch(async (ftpClient) => {
const size = await ftpClient.size(fullPath)

let modDate: Date | undefined = undefined
try {
modDate = await this.client.lastMod(fullPath)
} catch {
// This is not supported by every FTP server, ignore any error
}
return {
success: true,
fileInfo: {
size,
modified: modDate ? modDate.getTime() : 0,
},
}
let modDate: Date | undefined = undefined
try {
modDate = await ftpClient.lastMod(fullPath)
} catch {
// This is not supported by every FTP server, ignore any error
}
return {
success: true,
fileInfo: {
size,
modified: modDate ? modDate.getTime() : 0,
},
}
})
}
async upload(sourceStream: NodeJS.ReadableStream, fullPath: string): Promise<void> {
await this.init() // Ensure the client is connected

this.logger.silly(`Uploading file to: ${fullPath}`)

// Ensure the directory exists:
await this.client.ensureDir(path.dirname(fullPath))
await this.client.cd('/') // Revert to root after ensureDir
await this.client.batch(async (ftpClient) => {
// Ensure the directory exists:
await ftpClient.ensureDir(path.dirname(fullPath))
await ftpClient.cd('/') // Revert to root after ensureDir

// Remove the file if it already exists:
await this.client.remove(fullPath, true)
// Remove the file if it already exists:
await ftpClient.remove(fullPath, true)
Comment on lines +165 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

basic-ftp Client uploadFrom overwrite existing file behavior

💡 Result:

The basic-ftp Client's uploadFrom method overwrites existing files on the remote FTP server by default. If a file with the specified remotePath already exists, it will be overwritten with the data from the provided readable stream or local file.[1][2][3]

Citations:


🏁 Script executed:

cat -n shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts | sed -n '155,200p'

Repository: Sofie-Automation/sofie-package-manager

Length of output: 1998


Inconsistency: upload removes target before uploadFrom, uploadContent does not.

The upload() method explicitly calls remove(fullPath, true) before uploadFrom, while uploadContent() omits this step. Since uploadFrom overwrites existing files by default (per basic-ftp documentation), the explicit removal in upload() is redundant. Remove the unnecessary remove() call from upload() to align both methods and avoid defensive programming that masks the intended overwrite semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts`
around lines 165 - 166, Remove the redundant explicit removal in the upload()
method: the code currently calls ftpClient.remove(fullPath, true) before calling
ftpClient.uploadFrom, but uploadFrom already overwrites existing files; delete
the remove(...) call so upload() behaves consistently with uploadContent(),
keeping uploadFrom(uploadFromPath, fullPath) as the sole operation for writing
the file and preserving expected overwrite semantics in the upload and
uploadContent functions.


const response = await this.client.uploadFrom(Readable.from(sourceStream), fullPath)
// Upload the file:
const response = await ftpClient.uploadFrom(Readable.from(sourceStream), fullPath)

if (response.code !== 226) {
// 226 means "Transfer complete"
throw new Error(`Upload failed: [${response.code}]: ${response.message}`)
}
if (response.code !== 226) {
// 226 means "Transfer complete"
throw new Error(`Upload failed: [${response.code}]: ${response.message}`)
}
})
}
async uploadContent(fullPath: string, content: Buffer | string): Promise<void> {
await this.init() // Ensure the client is connected

this.logger.silly(`Uploading content to: ${fullPath}`)

// Ensure the directory exists:
await this.client.ensureDir(path.dirname(fullPath))
await this.client.cd('/') // Revert to root after ensureDir
await this.client.batch(async (ftpClient) => {
// Ensure the directory exists:
await ftpClient.ensureDir(path.dirname(fullPath))
await ftpClient.cd('/') // Revert to root after ensureDir

const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8')
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8')

// Feed the buffer into a readable stream:
const readableStream = Readable.from(buffer)
// Upload the readable stream:
const response = await this.client.uploadFrom(readableStream, fullPath)
// Feed the buffer into a readable stream:
const readableStream = Readable.from(buffer)
// Upload the readable stream:
const response = await ftpClient.uploadFrom(readableStream, fullPath)

if (response.code !== 226) {
// 226 means "Transfer complete"
throw new Error(`Upload failed [${response.code}]: ${response.message}`)
}
if (response.code !== 226) {
// 226 means "Transfer complete"
throw new Error(`Upload failed [${response.code}]: ${response.message}`)
}
})
}

async download(fullPath: string): Promise<FileDownloadReturnType> {
Expand Down Expand Up @@ -318,12 +286,94 @@

const response = await this._fileExists(fullPath)
if (response.exists) {
await this.client.removeDir(fullPath)
await this.client.cd('/') // Revert to root after removeDir
await this.client.batch(async (ftpClient) => {
await ftpClient.removeDir(fullPath)
await ftpClient.cd('/') // Revert to root after removeDir
})

return true
} else {
return false
}
}
}

/**
* Wraps FTP.Client. Ensures that only one method of FTP.Client is called at a time, by putting all calls in a queue.
* This is necessary because FTP.Client does not support concurrent calls, and will throw an error if multiple methods are called at the same time.
*/
class SafeFTPClient {
private ftpClient: FTP.Client

Check warning on line 306 in shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'ftpClient' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=Sofie-Automation_sofie-package-manager&issues=AZ20aIr-hwjXZlTK4KyU&open=AZ20aIr-hwjXZlTK4KyU&pullRequest=304
private queue: PQueue

Check warning on line 307 in shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'queue' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=Sofie-Automation_sofie-package-manager&issues=AZ20aIr-hwjXZlTK4KyV&open=AZ20aIr-hwjXZlTK4KyV&pullRequest=304

constructor() {
this.ftpClient = new FTP.Client()
this.queue = new PQueue({ concurrency: 1 })
}

/** execute multiple ftp-commands in batch */
async batch<T>(cb: (ftp: FTP.Client) => Promise<T>): Promise<T> {
return this.putInQueue('batch', [], async () => {
const result = await cb(this.ftpClient)
return result
})
}

get closed(): boolean {
return this.ftpClient.closed
}
close(): void {
return this.ftpClient.close()
}

async access(...args: Parameters<FTPInstance['access']>): ReturnType<FTPInstance['access']> {
return this.putInQueue('access', args, async () => this.ftpClient.access(...args))
}
Comment on lines +329 to +331

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid serializing arbitrary args into error messages — risk of leaking credentials.

putInQueue builds orgError with JSON.stringify(args) and the wrapper exposes access(...) (Line 329), whose argument is an FTP.AccessOptions object containing user and password. Today access is only invoked inside batch via the raw client, so the password is not stringified — but the public wrapper makes it easy for a future caller to invoke safeFtp.access(options) directly, at which point a thrown error will embed the password in the error stack (which typically lands in logs).

Consider either dropping args from the error string, redacting them, or only including the method name:

🛡️ Proposed hardening
-			const orgError = new Error(`Error executing ${methodName}: ${JSON.stringify(args)}`) // Used later
+			const orgError = new Error(`Error executing ${methodName}`) // Used later (args omitted to avoid leaking credentials/secrets)

Also applies to: 360-377

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@shared/packages/worker/src/worker/accessorHandlers/lib/FTPClient/FTPClient.ts`
around lines 329 - 331, The error construction in putInQueue currently includes
JSON.stringify(args) which can leak credentials when wrappers like
access(...args) (wrapper for FTPInstance.access) are called; update the
implementation so that sensitive data is not serialized — either remove args
from the orgError message, only include the method name, or redact known
sensitive keys (e.g., user, password) before any stringify; you can implement
this by adding a small sanitizer used by putInQueue (or by adding a flag on
calls from access and other wrappers at lines ~360-377) so errors log safe text
only and avoid embedding raw args.

async downloadTo(...args: Parameters<FTPInstance['downloadTo']>): ReturnType<FTPInstance['downloadTo']> {
return this.putInQueue('downloadTo', args, async () => this.ftpClient.downloadTo(...args))
}
async ensureDir(...args: Parameters<FTPInstance['ensureDir']>): ReturnType<FTPInstance['ensureDir']> {
return this.putInQueue('ensureDir', args, async () => this.ftpClient.ensureDir(...args))
}
async lastMod(...args: Parameters<FTPInstance['lastMod']>): ReturnType<FTPInstance['lastMod']> {
return this.putInQueue('lastMod', args, async () => this.ftpClient.lastMod(...args))
}
async list(...args: Parameters<FTPInstance['list']>): ReturnType<FTPInstance['list']> {
return this.putInQueue('list', args, async () => this.ftpClient.list(...args))
}
async remove(...args: Parameters<FTPInstance['remove']>): ReturnType<FTPInstance['remove']> {
return this.putInQueue('remove', args, async () => this.ftpClient.remove(...args))
}
async removeDir(...args: Parameters<FTPInstance['removeDir']>): ReturnType<FTPInstance['removeDir']> {
return this.putInQueue('removeDir', args, async () => this.ftpClient.removeDir(...args))
}
async rename(...args: Parameters<FTPInstance['rename']>): ReturnType<FTPInstance['rename']> {
return this.putInQueue('rename', args, async () => this.ftpClient.rename(...args))
}
async size(...args: Parameters<FTPInstance['size']>): ReturnType<FTPInstance['size']> {
return this.putInQueue('size', args, async () => this.ftpClient.size(...args))
}
async uploadFrom(...args: Parameters<FTPInstance['uploadFrom']>): ReturnType<FTPInstance['uploadFrom']> {
return this.putInQueue('uploadFrom', args, async () => this.ftpClient.uploadFrom(...args))
}

private async putInQueue<T, Args extends unknown[]>(
methodName: string,
args: Args,
cb: () => Promise<T>
): Promise<T> {
return this.queue.add(async () => {
const orgError = new Error(`Error executing ${methodName}: ${JSON.stringify(args)}`) // Used later
try {
const result = await cb()
return result
} catch (e) {
if (typeof e === 'object' && e !== null && 'stack' in e) {
e.stack += `\nOriginal stack: ${orgError.stack}`
}
throw e
}
})
}
}
type FTPInstance = InstanceType<typeof FTP.Client> // Short type for FTP.Client instance
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export class JSONWriteFilesBestEffortHandler extends JSONWriteHandler {
const newValue = cbManipulate(oldValue0?.value)
const newValueStr = newValue !== undefined ? JSON.stringify(newValue) : ''

if (oldValue0?.str === newValueStr) {
if (oldValue0?.str === newValueStr || (oldValue0 === undefined && newValue === undefined)) {
// do nothing
return
}
Expand Down
Loading