Potential fix for code scanning alert no. 2: Indirect uncontrolled command line#22
Potential fix for code scanning alert no. 2: Indirect uncontrolled command line#22eschultink wants to merge 1 commit intomainfrom
Conversation
…mmand line Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses a code-scanning alert for indirect command injection by switching from shell-based command execution to spawnSync with argument arrays for gcloud invocations.
Changes:
- Introduced
execGcloud()to rungcloudsafely without a shell and to surface failures. - Added argument builders for datastore restore/status commands and updated restore test flow to use them.
- Converted restore/status “command” helpers into printable string builders (rather than executable shell strings).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (result.status !== 0) { | ||
| const stderr = result.stderr || ''; | ||
| const stdout = result.stdout || ''; | ||
| throw new Error(`gcloud ${args.join(' ')} failed with code ${result.status}: ${stderr || stdout}`); |
There was a problem hiding this comment.
spawnSync can return status === null when the process is terminated by a signal; the current error message will report “code null” and lose the actual reason. Consider including result.signal (and formatting the message differently when status is null) so failures are diagnosable.
| throw new Error(`gcloud ${args.join(' ')} failed with code ${result.status}: ${stderr || stdout}`); | |
| const status = result.status; | |
| const signal = result.signal; | |
| const reason = (status === null && signal) | |
| ? `terminated by signal ${signal}` | |
| : `failed with code ${status}`; | |
| throw new Error(`gcloud ${args.join(' ')} ${reason}: ${stderr || stdout}`); |
| let backupMetadataFile = 'gs://' + bucket + '/' + timestamp + '/' + timestamp + '.overall_export_metadata' | ||
| return 'gcloud datastore import ' + authOptions + ' --kinds="' + kinds.join(',') + '" --async ' + backupMetadataFile ; | ||
| const args = buildDatastoreRestoreArgs(kinds, project, bucket, timestamp, options || {}); | ||
| return 'gcloud ' + args.map(String).join(' '); |
There was a problem hiding this comment.
Building a copy-pasteable command by joining args with spaces can produce an unsafe/misleading shell command when any arg contains whitespace or shell metacharacters (project IDs, account emails, or GCS paths with unusual characters). Since these strings are intended for users to paste into a shell, consider shell-escaping/quoting each arg (e.g., via a small escape helper or a vetted “shell-escape” utility) before joining.
| if (options && !_.isUndefined(options.account)) { | ||
| args.push('--account', options.account); | ||
| } |
There was a problem hiding this comment.
Account handling is inconsistent between the two arg builders: restore uses !_.isUndefined(options.account) while status uses a truthy check (if (options && options.account)). This can lead to different behavior for empty-string accounts or other falsy-but-present values. Consider using the same predicate in both builders to keep behavior predictable.
Potential fix for https://github.com/Worklytics/datastore-backup/security/code-scanning/2
In general, to fix this kind of issue you should avoid passing a single concatenated string to
child_process.exec/execSyncwhen it includes untrusted data. Instead, usespawn/spawnSync/execFile/execFileSyncwith an array of arguments. This prevents the shell from interpreting metacharacters and removes the need to manually escape arguments.For this specific code:
child_process.execSync(datastoreRestoreCommand(...))with a safe helper that:gcloud.child_process.spawnSync('gcloud', args, { encoding: 'utf8' })(or similar).datastoreRestoreCommandanddatastoreStatusCommandfrom returning shell command strings to returning arrays of arguments (or small objects containing{cmd, args}) that can be passed directly tospawnSync/ printed out for the user.index.js, where these helpers are used just for printing example commands (restoreaction, status monitoring lines), reconstruct printable command strings by joining the safe arg arrays with spaces, rather than using the old string-building helpers.Concretely, in
lib/datastore-backup.js:buildDatastoreRestoreArgs(kinds, project, bucket, timestamp, options)→ returns an array like:['datastore', 'import', '--project', project, '--kinds', 'Kind1,Kind2', '--async', backupMetadataFile, ...(account args)].buildDatastoreStatusArgs(project, options)→ returns:['datastore', 'operations', 'list', '--project', project, ...(account args)].execGcloud(args)→ callsspawnSync('gcloud', args, { encoding: 'utf8' })and returnsstdout(or throws on error).testRestoreFromBackupto:execGcloud(buildDatastoreRestoreArgs(...)).execGcloud(buildDatastoreStatusArgs(...)).datastoreRestoreCommandanddatastoreStatusCommandto be string-formatting wrappers around the arg builders, for use inindex.jswhere the user sees and copies commands. They should no longer be used withexecSync.In
index.js:datastoreRestoreCommand/datastoreStatusCommandthat return full, ready-to-rungcloudcommands as strings. This keeps the CLI output behavior unchanged.backupflow using the Datastore client library; that doesn’t use shell commands.listcommand still usesexecSync('gsutil ls ' + bucketPrefix ...), which is a similar pattern but not currently in the flagged path. Since we are constrained to address the provided alert and its path, and changing that might be considered functional behavior, we will not modify it here.This preserves functionality (the commands and printed examples remain the same) while ensuring the actually executed
gcloudcommands are invoked without a shell, eliminating the indirect command line injection risk.Suggested fixes powered by Copilot Autofix. Review carefully before merging.