CVE-2023-51664 (published 2023-12-27T17:15Z)

tj-actions/changed-files is a Github action to retrieve all files and directories. Prior to 41.0.0, the `tj-actions/changed-files` workflow allows for command injection in changed filenames, allowing an attacker to execute arbitrary code and potentially leak secrets. This issue may lead to arbitrary command execution in the GitHub Runner. This vulnerability has been addressed in version 41.0.0. Users are advised to upgrade.

CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-77
cwe_name: Improper Neutralization of Special Elements used in a Command ('Command Injection')
description: The product constructs all or part of a command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended command when it is sent to a downstream component.
extended_description: 
            Many protocols and products have their own custom command language. While OS or shell command strings are frequently discovered and targeted, developers may not realize that these other command languages might also be vulnerable to attacks.
         
         

Fix information:
repo_url: https://github.com/tj-actions/changed-files
commit_details: repo_url: https://github.com/tj-actions/changed-files
author: Jorge
author_date: 2023-12-22 22:07:32+01:00
msg: Merge pull request from GHSA-mcph-m25j-8j63

* feat: add `safe_output` input enabled by default

* fix: migrate README to safe uses of interpolation

* fix: README `uses` typo

* fix: README examples to account for newlines

* fix: README examples missing `safe_output`

* fix: remove sanitization of `'`

* fix: also sanitize `|&;`
repository: repo_url: https://github.com/tj-actions/changed-files
repo_name: tj-actions/changed-files
description: :octocat: Github action to retrieve all (added, copied, modified, deleted, renamed, type changed, unmerged, unknown) files and directories.
repo_language: TypeScript
owner: tj-actions
file_changes: filename: README.md
change_type: ModificationType.MODIFY
diff: @@ -123,14 +123,19 @@ jobs:
       - name: Get changed files
         id: changed-files
         uses: tj-actions/changed-files@v40
+        with:
+          safe_output: false # true by default, set to false because we are using an environment variable to store the output and avoid command injection.
         
         # To compare changes between the current commit and the last pushed remote commit set `since_last_remote_commit: true`. e.g
         # with:
         #   since_last_remote_commit: true 
 
       - name: List all changed files
+        env:
+          ALL_CHANGED_FILES: |-
+            ${{ steps.changed-files.outputs.all_changed_files }}
         run: |
-          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
+          for file in "$ALL_CHANGED_FILES"; do
             echo "$file was changed"
           done
 
@@ -139,14 +144,18 @@ jobs:
         id: changed-markdown-files
         uses: tj-actions/changed-files@v40
         with:
+          safe_output: false # true by default, set to false because we are using an environment variable to store the output and avoid command injection.
           # Avoid using single or double quotes for multiline patterns
           files: |
              **.md
 
       - name: List all changed files markdown files
         if: steps.changed-markdown-files.outputs.any_changed == 'true'
+        env:
+          ALL_CHANGED_FILES: |-
+            ${{ steps.changed-markdown-files.outputs.all_changed_files }}
         run: |
-          for file in ${{ steps.changed-markdown-files.outputs.all_changed_files }}; do
+          for file in "$ALL_CHANGED_FILES"; do
             echo "$file was changed"
           done
 
@@ -155,6 +164,7 @@ jobs:
         id: changed-files-yaml
         uses: tj-actions/changed-files@v40
         with:
+          safe_output: false # true by default, set to false because we are using an environment variable to store the output and avoid command injection.
           files_yaml: |
             doc:
               - '**.md'
@@ -170,29 +180,39 @@ jobs:
       - name: Run step if test file(s) change
         # NOTE: Ensure all outputs are prefixed by the same key used above e.g. `test_(...)` | `doc_(...)` | `src_(...)` when trying to access the `any_changed` output.
         if: steps.changed-files-yaml.outputs.test_any_changed == 'true'  
+        env:
+          TEST_ALL_CHANGED_FILES: |-
+            ${{ steps.changed-files-yaml.outputs.test_all_changed_files }}
         run: |
           echo "One or more test file(s) has changed."
-          echo "List all the files that have changed: ${{ steps.changed-files-yaml.outputs.test_all_changed_files }}"
+          echo "List all the files that have changed: $TEST_ALL_CHANGED_FILES"
       
       - name: Run step if doc file(s) change
         if: steps.changed-files-yaml.outputs.doc_any_changed == 'true'
+        env:
+          DOC_ALL_CHANGED_FILES: |-
+            ${{ steps.changed-files-yaml.outputs.doc_all_changed_files }}
         run: |
           echo "One or more doc file(s) has changed."
-          echo "List all the files that have changed: ${{ steps.changed-files-yaml.outputs.doc_all_changed_files }}"
+          echo "List all the files that have changed: $DOC_ALL_CHANGED_FILES"
 
       # Example 3
       - name: Get changed files in the docs folder
         id: changed-files-specific
         uses: tj-actions/changed-files@v40
         with:
+          safe_output: false # true by default, set to false because we are using an environment variable to store the output and avoid command injection.
           files: docs/*.{js,html}  # Alternatively using: `docs/**`
           files_ignore: docs/static.js
 
       - name: Run step if any file(s) in the docs folder change
         if: steps.changed-files-specific.outputs.any_changed == 'true'
+        env:
+          ALL_CHANGED_FILES: |-
+            ${{ steps.changed-files-specific.outputs.all_changed_files }}
         run: |
           echo "One or more files in the docs folder has changed."
-          echo "List all the files that have changed: ${{ steps.changed-files-specific.outputs.all_changed_files }}"
+          echo "List all the files that have changed: $ALL_CHANGED_FILES"
 ```
 
 #### Using Github's API :octocat:
@@ -224,10 +244,15 @@ jobs:
       - name: Get changed files
         id: changed-files
         uses: tj-actions/changed-files@v40
+        with:
+          safe_output: false # true by default, set to false because we are using an environment variable to store the output and avoid command injection.
 
       - name: List all changed files
+        env:
+          ALL_CHANGED_FILES: |-
+            ${{ steps.changed-files.outputs.all_changed_files }}
         run: |
-          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
+          for file in "$ALL_CHANGED_FILES"; do
             echo "$file was changed"
           done
 ```
@@ -265,12 +290,17 @@ jobs:
       - name: Get changed files
         id: changed-files
         uses: tj-actions/changed-files@v40
+        with:
+          safe_output: false # true by default, set to false because we are using an environment variable to store the output and avoid command injection.
 
       # NOTE: `since_last_remote_commit: true` is implied by default and falls back to the previous local commit.
 
       - name: List all changed files
+        env:
+          ALL_CHANGED_FILES: |-
+            ${{ steps.changed-files.outputs.all_changed_files }}
         run: |
-          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
+          for file in "$ALL_CHANGED_FILES"; do
             echo "$file was changed"
           done
       ...
@@ -715,10 +745,15 @@ See [inputs](#inputs) for more information.
     - name: Get changed files
       id: changed-files
       uses: tj-actions/changed-files@v40
+      with:
+        safe_output: false
 
     - name: List all added files
+      env:
+        ADDED_FILES: |-
+          ${{ steps.changed-files.outputs.added_files }}
       run: |
-        for file in ${{ steps.changed-files.outputs.added_files }}; do
+        for file in "$ADDED_FILES"; do
           echo "$file was added"
         done
 ...
@@ -736,6 +771,8 @@ See [outputs](#outputs) for a list of all available outputs.
     - name: Get changed files
       id: changed-files
       uses: tj-actions/changed-files@v40
+      with:
+        safe_output: false
 
     - name: Run a step if my-file.txt was modified
       if: contains(steps.changed-files.outputs.modified_files, 'my-file.txt')
@@ -756,8 +793,9 @@ See [outputs](#outputs) for a list of all available outputs.
 
    - name: Get changed files and write the outputs to a Txt file
      id: changed-files-write-output-files-txt
-     uses: ./
+     uses: tj-actions/changed-files@v40
      with:
+       safe_output: false
        write_output_files: true
 
    - name: Verify the contents of the .github/outputs/added_files.txt file
@@ -775,8 +813,9 @@ See [outputs](#outputs) for a list of all available outputs.
 ...
    - name: Get changed files and write the outputs to a JSON file
      id: changed-files-write-output-files-json
-     uses: ./
+     uses: tj-actions/changed-files@v40
      with:
+       safe_output: false
        json: true
        write_output_files: true
 
@@ -820,6 +859,7 @@ See [inputs](#inputs) for more information.
       id: changed-files-specific
       uses: tj-actions/changed-files@v40
       with:
+        safe_output: false
         files: |
           my-file.txt
           *.sh
@@ -840,15 +880,21 @@ See [inputs](#inputs) for more information.
 
     - name: Run step if any of the listed files above is deleted
       if: steps.changed-files-specific.outputs.any_deleted == 'true'
+      env:
+        DELETED_FILES: |-
+          ${{ steps.changed-files-specific.outputs.deleted_files }}
       run: |
-        for file in ${{ steps.changed-files-specific.outputs.deleted_files }}; do
+        for file in "$DELETED_FILES"; do
           echo "$file was deleted"
         done
 
     - name: Run step if all listed files above have been deleted
       if: steps.changed-files-specific.outputs.only_deleted == 'true'
+      env:
+        DELETED_FILES: |-
+          ${{ steps.changed-files-specific.outputs.deleted_files }}
       run: |
-        for file in ${{ steps.changed-files-specific.outputs.deleted_files }}; do
+        for file in "$DELETED_FILES"; do
           echo "$file was deleted"
         done
 ...
@@ -958,14 +1004,18 @@ jobs:
         id: changed-files-specific
         uses: tj-actions/changed-files@v40
         with:
+          safe_output: false
           base_sha: ${{ steps.get-base-sha.outputs.base_sha }}
           files: .github/**
 
       - name: Run step if any file(s) in the .github folder change
         if: steps.changed-files-specific.outputs.any_changed == 'true'
+        env:
+          ALL_CHANGED_FILES: |-
+            ${{ steps.changed-files-specific.outputs.all_changed_files }}
         run: |
           echo "One or more files in the .github folder has changed."
-          echo "List all the files that have changed: ${{ steps.changed-files-specific.outputs.all_changed_files }}"
+          echo "List all the files that have changed: $ALL_CHANGED_FILES"
 ...
 ```
 
@@ -988,11 +1038,15 @@ See [inputs](#inputs) for more information.
       id: changed-files-for-dir1
       uses: tj-actions/changed-files@v40
       with:
+        safe_output: false
         path: dir1
 
     - name: List all added files in dir1
+      env:
+        ADDED_FILES: |-
+          ${{ steps.changed-files-for-dir1.outputs.added_files }}
       run: |
-        for file in ${{ steps.changed-files-for-dir1.outputs.added_files }}; do
+        for file in "$ADDED_FILES"; do
           echo "$file was added"
         done
 ...
@@ -1015,7 +1069,7 @@ See [inputs](#inputs) for more information.
 
     - name: Run changed-files with quotepath disabled for a specified list of file(s)
       id: changed-files-quotepath-specific
-      uses: ./
+      uses: tj-actions/changed-files@v40
       with:
         files: test/test-è.txt
         quotepath: "false"

programming_language: Markdown
filename: action.yml
change_type: ModificationType.MODIFY
diff: @@ -134,6 +134,10 @@ inputs:
     description: "Escape JSON output."
     required: false
     default: "true"
+  safe_output:
+    description: "Apply sanitization to output filenames before being set as output."
+    required: false
+    default: "true"
   fetch_depth:
     description: "Depth of additional branch history fetched. NOTE: This can be adjusted to resolve errors with insufficient history."
     required: false

programming_language: Ruby
filename: changedFilesOutput.ts
change_type: ModificationType.MODIFY
diff: @@ -43,7 +43,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
   await setOutput({
     key: getOutputKey('added_files_count', outputPrefix),
@@ -64,7 +65,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -86,7 +88,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -108,7 +111,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -130,7 +134,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -152,7 +157,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -174,7 +180,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -199,7 +206,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -226,7 +234,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -314,7 +323,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({
@@ -419,7 +429,8 @@ export const setOutputsAndGetModifiedAndChangedFilesStatus = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 
   await setOutput({

programming_language: Swift
filename: inputs.ts
change_type: ModificationType.MODIFY
diff: @@ -34,6 +34,7 @@ export type Inputs = {
   dirNamesDeletedFilesIncludeOnlyDeletedDirs: boolean
   json: boolean
   escapeJson: boolean
+  safeOutput: boolean
   fetchDepth?: number
   fetchSubmoduleHistory: boolean
   sinceLastRemoteCommit: boolean
@@ -154,6 +155,7 @@ export const getInputs = (): Inputs => {
   )
   const json = core.getBooleanInput('json', {required: false})
   const escapeJson = core.getBooleanInput('escape_json', {required: false})
+  const safeOutput = core.getBooleanInput('safe_output', {required: false})
   const fetchDepth = core.getInput('fetch_depth', {required: false})
   const sinceLastRemoteCommit = core.getBooleanInput(
     'since_last_remote_commit',
@@ -272,6 +274,7 @@ export const getInputs = (): Inputs => {
     dirNamesIncludeFilesSeparator,
     json,
     escapeJson,
+    safeOutput,
     writeOutputFiles,
     outputDir,
     outputRenamedFilesAsDeletedAndAdded,

programming_language: TypeScript
filename: main.ts
change_type: ModificationType.MODIFY
diff: @@ -173,7 +173,8 @@ const getChangedFilesFromLocalGitHistory = async ({
       value: allOldNewRenamedFiles.paths,
       writeOutputFiles: inputs.writeOutputFiles,
       outputDir: inputs.outputDir,
-      json: inputs.json
+      json: inputs.json,
+      safeOutput: inputs.safeOutput
     })
     await setOutput({
       key: 'all_old_new_renamed_files_count',

programming_language: JavaScript
filename: utils.ts
change_type: ModificationType.MODIFY
diff: @@ -1324,7 +1324,8 @@ export const setArrayOutput = async ({
     writeOutputFiles: inputs.writeOutputFiles,
     outputDir: inputs.outputDir,
     json: inputs.json,
-    shouldEscape: inputs.escapeJson
+    shouldEscape: inputs.escapeJson,
+    safeOutput: inputs.safeOutput
   })
 }
 
@@ -1334,7 +1335,8 @@ export const setOutput = async ({
   writeOutputFiles,
   outputDir,
   json = false,
-  shouldEscape = false
+  shouldEscape = false,
+  safeOutput = false
 }: {
   key: string
   value: string | string[] | boolean
@@ -1342,6 +1344,7 @@ export const setOutput = async ({
   outputDir: string
   json?: boolean
   shouldEscape?: boolean
+  safeOutput?: boolean
 }): Promise<void> => {
   let cleanedValue
   if (json) {
@@ -1350,6 +1353,11 @@ export const setOutput = async ({
     cleanedValue = value.toString().trim()
   }
 
+  // if safeOutput is true, escape special characters for bash shell
+  if (safeOutput) {
+    cleanedValue = cleanedValue.replace(/[$()`|&;]/g, '\\$&')
+  }
+
   core.setOutput(key, cleanedValue)
 
   if (writeOutputFiles) {

programming_language: TypeScript
repo_url: https://github.com/tj-actions/changed-files
commit_details: repo_url: https://github.com/tj-actions/changed-files
author: Tonye Jack
author_date: 2023-12-24 09:32:32-07:00
msg: fix: update characters escaped by safe output (#1815)
repository: repo_url: https://github.com/tj-actions/changed-files
repo_name: tj-actions/changed-files
description: :octocat: Github action to retrieve all (added, copied, modified, deleted, renamed, type changed, unmerged, unknown) files and directories.
repo_language: TypeScript
owner: tj-actions
file_changes: filename: utils.ts
change_type: ModificationType.MODIFY
diff: @@ -1355,10 +1355,7 @@ export const setOutput = async ({
 
   // if safeOutput is true, escape special characters for bash shell
   if (safeOutput) {
-    cleanedValue = cleanedValue.replace(
-      /[^\x20-\x7E]|[:*?"<>|;`$()&!]/g,
-      '\\$&'
-    )
+    cleanedValue = cleanedValue.replace(/[^\x20-\x7E]|[:*?<>|;`$()&!]/g, '\\$&')
   }
 
   core.setOutput(key, cleanedValue)

programming_language: TypeScript
repo_url: https://github.com/tj-actions/changed-files
commit_details: repo_url: https://github.com/tj-actions/changed-files
author: tj-actions[bot]
author_date: 2023-12-23 03:56:31-07:00
msg: fix: update safe output regex and the docs (#1805)

Co-authored-by: jackton1 <jackton1@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Tonye Jack <jtonye@ymail.com>
repository: repo_url: https://github.com/tj-actions/changed-files
repo_name: tj-actions/changed-files
description: :octocat: Github action to retrieve all (added, copied, modified, deleted, renamed, type changed, unmerged, unknown) files and directories.
repo_language: TypeScript
owner: tj-actions
file_changes: filename: README.md
change_type: ModificationType.MODIFY
diff: @@ -572,6 +572,12 @@ Support this project with a :star:
     # Default: "\n"
     recover_files_separator: ''
 
+    # Apply sanitization to output filenames before being set as 
+    # output. 
+    # Type: boolean
+    # Default: "true"
+    safe_output: ''
+
     # Split character for output strings.
     # Type: string
     # Default: " "

programming_language: Markdown
filename: utils.ts
change_type: ModificationType.MODIFY
diff: @@ -1355,7 +1355,10 @@ export const setOutput = async ({
 
   // if safeOutput is true, escape special characters for bash shell
   if (safeOutput) {
-    cleanedValue = cleanedValue.replace(/[$()`|&;]/g, '\\$&')
+    cleanedValue = cleanedValue.replace(
+      /[^\x20-\x7E]|[:*?"<>|;`$()&!]/g,
+      '\\$&'
+    )
   }
 
   core.setOutput(key, cleanedValue)

programming_language: TypeScript