Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 2 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ inputs:
required: true
cache-key:
required: true
restore-keys:
required: false
paths:
required: true
command:
Expand Down
66 changes: 57 additions & 9 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64582,6 +64582,7 @@ async function run() {
try {
const s3Bucket = core.getInput('s3-bucket', { required: true });
const cacheKey = core.getInput('cache-key', { required: true });
const restoreKeys = core.getInput('restore-keys', { required: false }).split('\n').map(x => x.trim()).filter(x => x);
const paths = core.getInput('paths', { required: true });
const command = core.getInput('command', { required: true });
const tarOption = core.getInput('tar-option', { required: false });
Expand All @@ -64602,33 +64603,80 @@ async function run() {
Key: fileName,
};

let foundKey = null;
let exactMatch = false;
let contentLength;

try {
const headObject = await s3.headObject(params).promise();
contentLength = headObject.ContentLength;
foundKey = fileName;
exactMatch = true;
} catch (headErr) {
// Not found exact match
}

if (!foundKey && restoreKeys.length > 0) {
for (const keyPrefix of restoreKeys) {
const listParams = {
Bucket: s3Bucket,
Prefix: keyPrefix
};
const listedObjects = await s3.listObjectsV2(listParams).promise();
if (listedObjects.Contents && listedObjects.Contents.length > 0) {
const matches = listedObjects.Contents
.filter(obj => obj.Key.endsWith('.tar.zst'))
.sort((a, b) => b.LastModified - a.LastModified);

if (matches.length > 0) {
foundKey = matches[0].Key;
contentLength = matches[0].Size;
break;
}
}
}
}

if (!foundKey) {
console.log(`No cache is found for key: ${fileName}`);
await exec.exec(command); // install or build command e.g. npm ci, npm run dev
core.saveState('cache-upload', true);
return;
}

// Skip command to save time
if (exactMatch && cacheHitSkip) {
console.log(`Cache found, skipping command: ${command}`);
return;
}

// Cache found. Download and extract
console.log(`Found a cache for key: ${foundKey}`);
const fileStream = fs.createWriteStream(fileName);
const s3Stream = downloader.download(params, {
const s3Stream = downloader.download({
Bucket: s3Bucket,
Key: foundKey
}, {
totalObjectSize: contentLength,
concurrentStreams: 20,
});
s3Stream.pipe(fileStream);
s3Stream.on('downloaded', async () => {
console.log(`Found a cache for key: ${fileName}`);
if (cacheHitSkip) {
console.log(`Cache found, skipping command: ${command}`);
return;
}
await exec.exec(`tar ${untarOption} ${fileName}`);
await exec.exec(`rm -f ${fileName}`);

await new Promise((resolve, reject) => {
s3Stream.on('downloaded', resolve);
s3Stream.on('error', reject);
fileStream.on('error', reject);
});

await exec.exec(`tar ${untarOption} ${fileName}`);
await exec.exec(`rm -f ${fileName}`);
console.log(`Restored from restore-key: ${foundKey}`);

// When fuzzy matched cache, will need to upload latest files in post step.
// Require workflow to run additional steps to update the cache files otherwise uploaded cache will not match
if(!exactMatch) {
core.saveState('cache-upload', true);
}
} catch (error) {
core.setFailed(error.message);
}
Expand Down
66 changes: 57 additions & 9 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ async function run() {
try {
const s3Bucket = core.getInput('s3-bucket', { required: true });
const cacheKey = core.getInput('cache-key', { required: true });
const restoreKeys = core.getInput('restore-keys', { required: false }).split('\n').map(x => x.trim()).filter(x => x);
const paths = core.getInput('paths', { required: true });
const command = core.getInput('command', { required: true });
const tarOption = core.getInput('tar-option', { required: false });
Expand All @@ -31,33 +32,80 @@ async function run() {
Key: fileName,
};

let foundKey = null;
let exactMatch = false;
let contentLength;

try {
const headObject = await s3.headObject(params).promise();
contentLength = headObject.ContentLength;
foundKey = fileName;
exactMatch = true;
} catch (headErr) {
// Not found exact match
}

if (!foundKey && restoreKeys.length > 0) {
for (const keyPrefix of restoreKeys) {
const listParams = {
Bucket: s3Bucket,
Prefix: keyPrefix
};
const listedObjects = await s3.listObjectsV2(listParams).promise();
if (listedObjects.Contents && listedObjects.Contents.length > 0) {
const matches = listedObjects.Contents
.filter(obj => obj.Key.endsWith('.tar.zst'))
.sort((a, b) => b.LastModified - a.LastModified);

if (matches.length > 0) {
foundKey = matches[0].Key;
contentLength = matches[0].Size;
break;
}
}
}
}

if (!foundKey) {
console.log(`No cache is found for key: ${fileName}`);
await exec.exec(command); // install or build command e.g. npm ci, npm run dev
core.saveState('cache-upload', true);
return;
}

// Skip command to save time
if (exactMatch && cacheHitSkip) {
console.log(`Cache found, skipping command: ${command}`);
return;
}

// Cache found. Download and extract
console.log(`Found a cache for key: ${foundKey}`);
const fileStream = fs.createWriteStream(fileName);
const s3Stream = downloader.download(params, {
const s3Stream = downloader.download({
Bucket: s3Bucket,
Key: foundKey
}, {
totalObjectSize: contentLength,
concurrentStreams: 20,
});
s3Stream.pipe(fileStream);
s3Stream.on('downloaded', async () => {
console.log(`Found a cache for key: ${fileName}`);
if (cacheHitSkip) {
console.log(`Cache found, skipping command: ${command}`);
return;
}
await exec.exec(`tar ${untarOption} ${fileName}`);
await exec.exec(`rm -f ${fileName}`);

await new Promise((resolve, reject) => {
s3Stream.on('downloaded', resolve);
s3Stream.on('error', reject);
fileStream.on('error', reject);
});

await exec.exec(`tar ${untarOption} ${fileName}`);
await exec.exec(`rm -f ${fileName}`);
Comment on lines +100 to +101
Copy link

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:

cat -n src/index.js | sed -n '95,110p'

Repository: getjerry/actions-s3-cache

Length of output: 841


🏁 Script executed:

cat -n src/index.js | head -120 | tail -50

Repository: getjerry/actions-s3-cache

Length of output: 1958


🏁 Script executed:

cat -n src/index.js | head -70

Repository: getjerry/actions-s3-cache

Length of output: 2966


🏁 Script executed:

npm list @actions/exec 2>/dev/null || echo "Checking package.json"

Repository: getjerry/actions-s3-cache

Length of output: 140


🏁 Script executed:

cat package.json

Repository: getjerry/actions-s3-cache

Length of output: 524


🌐 Web query:

@actions/exec API documentation version 1.1.1 exec function arguments array

💡 Result:

The exec function accepts an arguments array as its second parameter: a string[] of individual arguments (no shell splitting). Usage example:
await exec.exec('node', ['index.js', 'foo=bar'], options)
Sources: [1] npm @actions/exec (v1.1.1) README. [2] npm.io package page.


🏁 Script executed:

# Verify graceful-fs supports fs.promises
grep -r "fs.promises" . --include="*.js" | head -5 || echo "Checking Node.js compatibility"

Repository: getjerry/actions-s3-cache

Length of output: 641


Avoid shell-string tar/rm execution (command injection vulnerability).
untarOption is user-controlled input from GitHub Actions and is interpolated directly into a shell command at line 100, allowing command injection. Use exec.exec('tar', args) with properly split arguments and fs.promises.unlink instead of rm.

-    await exec.exec(`tar ${untarOption} ${fileName}`);
-    await exec.exec(`rm -f ${fileName}`);
+    const untarArgs = (untarOption || '').split(/\s+/).filter(Boolean);
+    await exec.exec('tar', [...untarArgs, fileName]);
+    await fs.promises.unlink(fileName).catch(() => {});
🤖 Prompt for AI Agents
In src/index.js around lines 100-101, the current code interpolates
user-controlled untarOption into a shell string for tar and uses rm via shell,
causing command-injection risk; change to call exec.exec('tar', argsArray) where
argsArray is a safe array (validate or whitelist allowed flags/options then push
fileName as a separate element) so no shell interpolation occurs, and replace
the rm -f call with await fs.promises.unlink(fileName) (wrapped in try/catch to
handle missing files) to delete the file safely.

console.log(`Restored from restore-key: ${foundKey}`);

// When fuzzy matched cache, will need to upload latest files in post step.
// Require workflow to run additional steps to update the cache files otherwise uploaded cache will not match
if(!exactMatch) {
core.saveState('cache-upload', true);
}
} catch (error) {
core.setFailed(error.message);
}
Expand Down
Loading