Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/merge_main.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
name: Git Release Creator
on:
workflow_dispatch:
push:
branches:
- main
Expand Down Expand Up @@ -30,7 +31,7 @@ jobs:
- name: Create Release
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
id: create_release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
with:
tag_name: ${{ env.version }}
name: ${{ env.version }}
Expand Down
99 changes: 30 additions & 69 deletions src/secrets.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,78 +102,31 @@ async function exportRotatedSecrets(akeylessToken, rotatedSecrets, apiUrl, expor
token: akeylessToken,
names: [secretName]
});
// Avoid logging token; only log requested name.
core.debug(`[rotatedSecrets] Requesting rotated secret name='${secretName}'`);

let rotatedSecret;
try {
rotatedSecret = await api.getRotatedSecretValue(param);
} catch (error) {
core.debug(`[rotatedSecrets] getRotatedSecretValue API error for '${secretName}': ${typeof error === 'object' ? JSON.stringify(error) : error}`);
core.setFailed('Failed to fetch rotated secret');
continue; // proceed to next secret instead of aborting all
}
let rotatedSecret = await api.getRotatedSecretValue(param).catch(error => {
core.debug(`getRotatedSecretValue Failed for secret '${secretName}': ${JSON.stringify(error)}`);
const errorMessage = error?.body?.error || error?.message || JSON.stringify(error);
core.setFailed(`Failed to get rotated secret '${secretName}': ${errorMessage}`);
throw error;
});

if (!rotatedSecret) {
core.debug(`[rotatedSecrets] Empty response object for '${secretName}'`);
continue;
}

// Provide a sanitized summary (no secret values) to help debug varying shapes.
try {
const summary = {
topLevelKeys: Object.keys(rotatedSecret || {}),
hasValueProp: rotatedSecret && Object.prototype.hasOwnProperty.call(rotatedSecret, 'value'),
valueType: rotatedSecret && rotatedSecret.value ? typeof rotatedSecret.value : 'undefined',
valueIsObject: rotatedSecret && rotatedSecret.value && typeof rotatedSecret.value === 'object',
valueKeys: rotatedSecret && rotatedSecret.value && typeof rotatedSecret.value === 'object' ? Object.keys(rotatedSecret.value) : undefined,
containsSecretNameKey: rotatedSecret && rotatedSecret.value && typeof rotatedSecret.value === 'object' ? Object.prototype.hasOwnProperty.call(rotatedSecret.value, secretName) : false,
};
core.debug(`[rotatedSecrets] Response summary for '${secretName}': ${JSON.stringify(summary)}`);
} catch (e) {
core.debug(`[rotatedSecrets] Failed to build response summary for '${secretName}': ${e.message}`);
}

// SDK returns an object under value. We want the specific secret's value (string) for parsing.
let exportedValue;
const hasDirectKey = rotatedSecret.value && Object.prototype.hasOwnProperty.call(rotatedSecret.value, secretName);
if (hasDirectKey) {
exportedValue = rotatedSecret.value[secretName];
if (exportedValue && typeof exportedValue === 'string') {
core.debug(`[rotatedSecrets] Using direct string value for '${secretName}' (length=${exportedValue.length})`);
} else {
core.debug(`[rotatedSecrets] Direct key for '${secretName}' exists but value type='${typeof exportedValue}'`);
}
} else {
// Fallback: if shape is different (e.g., username/password keys), stringify full value for optional JSON parsing
core.debug(`[rotatedSecrets] Falling back to serializing entire value object for '${secretName}'`);
try {
exportedValue = JSON.stringify(rotatedSecret.value);
} catch (e) {
core.debug(`[rotatedSecrets] JSON stringify fallback failed for '${secretName}': ${e.message}`);
exportedValue = rotatedSecret.value; // last resort
}
core.setFailed(`No response received for rotated secret '${secretName}'`);
return;
}

// Final debug before exporting (do not log actual secret contents)
if (typeof exportedValue === 'string') {
core.debug(`[rotatedSecrets] Prepared export value for '${secretName}' as string length=${exportedValue.length}`);
} else if (exportedValue && typeof exportedValue === 'object') {
core.debug(`[rotatedSecrets] Prepared export value for '${secretName}' as object keys=${Object.keys(exportedValue).join(',')}`);
} else {
core.debug(`[rotatedSecrets] Prepared export value for '${secretName}' type='${typeof exportedValue}'`);
}

try {
setOutput(exportedValue, rotateParams, exportSecretsToOutputs, exportSecretsToEnvironment, parseJsonSecrets)
} catch (e) {
core.debug(`[rotatedSecrets] setOutput failed for '${secretName}': ${e.message}`);
core.setFailed('Failed to export rotated secret');
let secretValue = rotatedSecret.value;

if (parseJsonSecrets && typeof secretValue === 'object' && secretValue !== null) {
secretValue = JSON.stringify(secretValue);
}

setOutput(secretValue, rotateParams, exportSecretsToOutputs, exportSecretsToEnvironment, parseJsonSecrets)
}
} catch (error) {
core.debug(`Failed to export rotated secret: ${typeof error === 'object' ? JSON.stringify(error) : error}`);
core.setFailed('Failed to export rotated secret');
core.debug(`Failed to export rotated secret '${secretName}': ${typeof error === 'object' ? JSON.stringify(error) : error}`);
const errorMessage = error?.body?.error || error?.message || 'Unknown error';
core.setFailed(`Failed to export rotated secret '${secretName}': ${errorMessage}`);
}
}

Expand Down Expand Up @@ -284,12 +237,20 @@ function validateNoDuplicateKeys(parsedJson) {
}

function parseJson(jsonString) {
try {
const parsedJson = JSON.parse(jsonString);
return parsedJson;
} catch (e) {
return null;
if (typeof jsonString === 'object' && jsonString !== null) {
return jsonString;
}

if (typeof jsonString === 'string') {
try {
const parsedJson = JSON.parse(jsonString);
return parsedJson;
} catch (e) {
return null;
}
}

return null;
}

async function handleCreateSecrets(args) {
Expand Down
150 changes: 150 additions & 0 deletions tests/secrets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,156 @@ describe('testing secret exports', () => {
expect(core.setOutput).not.toHaveBeenCalledWith('my_second_secret', expect.anything());
});

it('should export rotated secret with parse-json-secrets=true (fix for object response)', async function () {
const args = {
akeylessToken: "akeylessToken",
staticSecrets: undefined,
dynamicSecrets: undefined,
rotatedSecrets: [
{
"name": "/some/rotated/secret",
"prefix-json-secrets": "CREDS"
}
],
apiUrl: 'https://api.akeyless.io',
exportSecretsToOutputs: true,
exportSecretsToEnvironment: true,
parseJsonSecrets: true,
sshCertificate: undefined,
pkiCertificate: undefined
}
const api = {
getRotatedSecretValue: jest.fn(),
};
akeylessApi.api.mockReturnValue(api);

// Simulate the real API response where value is an object (not a string)
api.getRotatedSecretValue.mockResolvedValueOnce({
value: {
"username": "testuser",
"password": "testpass123",
"host": "db.example.com"
},
});

core.setSecret = jest.fn();
core.setOutput = jest.fn();
core.exportVariable = jest.fn();

await secrets.handleExportSecrets(args)

expect(akeylessApi.api).toHaveBeenCalledWith(args.apiUrl);
expect(api.getRotatedSecretValue).toHaveBeenCalledTimes(1);
expect(api.getRotatedSecretValue).toHaveBeenCalledWith({
token: args.akeylessToken,
names: "/some/rotated/secret",
});

// Verify that individual JSON fields are exported with the prefix
expect(core.setSecret).toHaveBeenCalledTimes(4); // 3 fields + token
expect(core.setOutput).toHaveBeenCalledWith('CREDS_USERNAME', 'testuser');
expect(core.setOutput).toHaveBeenCalledWith('CREDS_PASSWORD', 'testpass123');
expect(core.setOutput).toHaveBeenCalledWith('CREDS_HOST', 'db.example.com');
expect(core.exportVariable).toHaveBeenCalledWith('CREDS_USERNAME', 'testuser');
expect(core.exportVariable).toHaveBeenCalledWith('CREDS_PASSWORD', 'testpass123');
expect(core.exportVariable).toHaveBeenCalledWith('CREDS_HOST', 'db.example.com');

// Verify that the entire object is NOT exported as "undefined" (old bug)
const outputCalls = core.setOutput.mock.calls;
const undefinedOutput = outputCalls.find(call => call[0] === undefined);
expect(undefinedOutput).toBeUndefined();
});

it('should export rotated secret with parse-json-secrets=true and no prefix', async function () {
const args = {
akeylessToken: "akeylessToken",
staticSecrets: undefined,
dynamicSecrets: undefined,
rotatedSecrets: [
{
"name": "/database/credentials"
}
],
apiUrl: 'https://api.akeyless.io',
exportSecretsToOutputs: true,
exportSecretsToEnvironment: false,
parseJsonSecrets: true,
sshCertificate: undefined,
pkiCertificate: undefined
}
const api = {
getRotatedSecretValue: jest.fn(),
};
akeylessApi.api.mockReturnValue(api);

// Simulate the real API response where value is an object
api.getRotatedSecretValue.mockResolvedValueOnce({
value: {
"db_user": "admin",
"db_password": "secret123"
},
});

core.setSecret = jest.fn();
core.setOutput = jest.fn();
core.exportVariable = jest.fn();

await secrets.handleExportSecrets(args)

expect(api.getRotatedSecretValue).toHaveBeenCalledTimes(1);

// Verify that individual JSON fields are exported with default prefix (from path)
expect(core.setSecret).toHaveBeenCalledTimes(3); // 2 fields + token
expect(core.setOutput).toHaveBeenCalledWith('DATABASE_CREDENTIALS_DB_USER', 'admin');
expect(core.setOutput).toHaveBeenCalledWith('DATABASE_CREDENTIALS_DB_PASSWORD', 'secret123');
});

it('should export rotated secret with parseJsonSecrets=false (backward compatibility)', async function () {
const args = {
akeylessToken: "akeylessToken",
staticSecrets: undefined,
dynamicSecrets: undefined,
rotatedSecrets: [{"name":"/some/rotated/secret","output-name":"my_rotated_secret"}],
apiUrl: 'https://api.akeyless.io',
exportSecretsToOutputs: true,
exportSecretsToEnvironment: true,
parseJsonSecrets: false,
sshCertificate: undefined,
pkiCertificate: undefined
}
const api = {
getRotatedSecretValue: jest.fn(),
};
akeylessApi.api.mockReturnValue(api);

// API returns object (real-world scenario)
api.getRotatedSecretValue.mockResolvedValueOnce({
value: {
"username": "testuser",
"password": "testpass123"
},
});

core.setSecret = jest.fn();
core.setOutput = jest.fn();
core.exportVariable = jest.fn();

await secrets.handleExportSecrets(args)

expect(api.getRotatedSecretValue).toHaveBeenCalledTimes(1);

// With parseJsonSecrets=false, the object should be exported as-is (old behavior)
expect(core.setSecret).toHaveBeenCalledTimes(2); // 1 secret + token
expect(core.setOutput).toHaveBeenCalledWith('my_rotated_secret', {
"username": "testuser",
"password": "testpass123"
});
expect(core.exportVariable).toHaveBeenCalledWith('my_rotated_secret', {
"username": "testuser",
"password": "testpass123"
});
});

it('should export ssh certificate secret', async function () {
const args = {
akeylessToken: "akeylessToken",
Expand Down
2 changes: 1 addition & 1 deletion version
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# Use Semantic versioning only. Please update the version number before opening a pull request.
v1.1.2
v1.1.4
Loading