From dfc41235de75763803eca10ba3b0c703f175c2d9 Mon Sep 17 00:00:00 2001 From: GregoireW <24318548+GregoireW@users.noreply.github.com> Date: Mon, 2 Sep 2024 11:35:47 +0200 Subject: [PATCH] Add credHelpers to produce credentials Signed-off-by: GregoireW <24318548+GregoireW@users.noreply.github.com> --- .changeset/plenty-knives-kneel.md | 5 + eslint.config.mjs | 4 +- .../oci/src/__tests__/credentials.test.ts | 93 +++++++++++++++++++ packages/oci/src/credentials.ts | 90 ++++++++++++++---- 4 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 .changeset/plenty-knives-kneel.md diff --git a/.changeset/plenty-knives-kneel.md b/.changeset/plenty-knives-kneel.md new file mode 100644 index 00000000..d329b356 --- /dev/null +++ b/.changeset/plenty-knives-kneel.md @@ -0,0 +1,5 @@ +--- +'@sigstore/oci': minor +--- + +getRegistryCredentials can now use credHelpers from docker configuration file diff --git a/eslint.config.mjs b/eslint.config.mjs index b148eec3..b61ad28b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,7 @@ const compat = new FlatCompat({ }); export default [{ - ignores: ["**/node_modules", "**/dist", "**/__generated__", "**/__fixtures__", "**/jest.config.js", "**/jest.config.base.js"], + ignores: ["**/node_modules", "**/dist", "**/__generated__", "**/__fixtures__", "**/jest.config.js", "**/jest.config.base.js", "**/hack"], }, ...compat.extends( "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", @@ -32,4 +32,4 @@ export default [{ "@typescript-eslint/no-unused-vars": ["error", { "caughtErrors": "none" }], "@typescript-eslint/no-require-imports": "off" }, -}]; \ No newline at end of file +}]; diff --git a/packages/oci/src/__tests__/credentials.test.ts b/packages/oci/src/__tests__/credentials.test.ts index c0d4c54c..8fa04e1a 100644 --- a/packages/oci/src/__tests__/credentials.test.ts +++ b/packages/oci/src/__tests__/credentials.test.ts @@ -15,6 +15,7 @@ limitations under the License. */ import fs from 'fs'; import os from 'os'; +import child_process, { ExecFileSyncOptions } from 'node:child_process'; import path from 'path'; import { fromBasicAuth, @@ -25,7 +26,10 @@ import { describe('getRegistryCredentials', () => { const registryName = 'my-registry'; const imageName = `${registryName}/my-image`; + const badRegistryName = 'bad-registry'; + const imageNameBadRegistry = `${badRegistryName}/my-image`; let homedirSpy: jest.SpyInstance | undefined; + let execSpy: jest.SpyInstance | undefined; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'get-reg-creds-')); const dockerDir = path.join(tempDir, '.docker'); fs.mkdirSync(dockerDir, { recursive: true }); @@ -33,10 +37,27 @@ describe('getRegistryCredentials', () => { beforeEach(() => { homedirSpy = jest.spyOn(os, 'homedir'); homedirSpy.mockReturnValue(tempDir); + + execSpy = jest.spyOn(child_process, 'execFileSync'); + execSpy?.mockImplementation((file, args, options) => { + if (file!=="docker-credential-fake" || args?.length!=1 || args[0]!=="get"){ + throw "Invalid arguments"; + } + + if (options?.input === `${registryName}`) { + const credentials = { + Username: 'username', + Secret: 'password' + }; + return JSON.stringify(credentials); + } + throw "Invalid registry"; + }); }); afterEach(() => { homedirSpy?.mockRestore(); + execSpy?.mockRestore(); }); afterAll(() => { @@ -168,8 +189,80 @@ describe('getRegistryCredentials', () => { expect(creds).toEqual({ username, password }); }); }); + + describe('when credHelper exit in error', () => { + const dockerConfig = { + credHelpers: { + [badRegistryName]: "fake" + } + }; + + beforeEach(() => { + fs.writeFileSync( + path.join(tempDir, '.docker', 'config.json'), + JSON.stringify(dockerConfig), + {} + ); + }); + + it('throws an error', () => { + expect(() => getRegistryCredentials(imageNameBadRegistry)).toThrow( + /Failed to get credentials from helper fake for registry bad-registry/i + ); + }); + }); + + + describe('when credHelper exist for the registry', () => { + const username = 'username'; + const password = 'password'; + + const dockerConfig = { + credHelpers: { + [registryName]: "fake" + } + }; + + beforeEach(() => { + fs.writeFileSync( + path.join(tempDir, '.docker', 'config.json'), + JSON.stringify(dockerConfig), + {} + ); + }); + + it('returns the credentials', () => { + const creds = getRegistryCredentials(imageName); + + expect(creds).toEqual({ username, password }); + }); + }); + + describe('when credsStore exist', () => { + const username = 'username'; + const password = 'password'; + + const dockerConfig = { + credsStore: "fake" + }; + + beforeEach(() => { + fs.writeFileSync( + path.join(tempDir, '.docker', 'config.json'), + JSON.stringify(dockerConfig), + {} + ); + }); + + it('returns the credentials', () => { + const creds = getRegistryCredentials(imageName); + + expect(creds).toEqual({ username, password }); + }); + }); }); + describe('toBasicAuth', () => { const creds = { username: 'user', password: 'pass' }; const expected = 'dXNlcjpwYXNz'; diff --git a/packages/oci/src/credentials.ts b/packages/oci/src/credentials.ts index 038ffc14..6a23df85 100644 --- a/packages/oci/src/credentials.ts +++ b/packages/oci/src/credentials.ts @@ -16,6 +16,7 @@ limitations under the License. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { parseImageName } from './name'; export type Credentials = { @@ -24,24 +25,13 @@ export type Credentials = { }; type DockerConifg = { - auths?: { [registry: string]: { auth: string; identitytoken?: string } }; + credsStore?: string, + auths?: { [registry: string]: { auth: string; identitytoken?: string } }, + credHelpers?: { [registry: string]: string }, }; // Returns the credentials for a given registry by reading the Docker config -// file. -export const getRegistryCredentials = (imageName: string): Credentials => { - const { registry } = parseImageName(imageName); - const dockerConfigFile = path.join(os.homedir(), '.docker', 'config.json'); - - let content: string | undefined; - try { - content = fs.readFileSync(dockerConfigFile, 'utf8'); - } catch (err) { - throw new Error(`No credential file found at ${dockerConfigFile}`); - } - - const dockerConfig: DockerConifg = JSON.parse(content); - +function credentialFromAuths(dockerConfig: DockerConifg, registry: string) { const credKey = Object.keys(dockerConfig?.auths || {}).find((key) => key.includes(registry) @@ -49,7 +39,7 @@ export const getRegistryCredentials = (imageName: string): Credentials => { const creds = dockerConfig?.auths?.[credKey]; if (!creds) { - throw new Error(`No credentials found for registry ${registry}`); + return null; } // Extract username/password from auth string @@ -59,6 +49,74 @@ export const getRegistryCredentials = (imageName: string): Credentials => { const pass = creds.identitytoken ? creds.identitytoken : password; return { username, password: pass }; +} + +function credentialFromCredHelpers(dockerConfig: DockerConifg, registry: string) { + // Check if the registry has a credHelper and use it if it does + const helper = dockerConfig?.credHelpers?.[registry]; + + if (!helper) { + return null; + } + + return launchHelper(helper, registry); +} + +function credentialFromCredsStore(dockerConfig: DockerConifg, registry: string) { + // If the credsStore is set, use it to get the credentials + const helper = dockerConfig?.credsStore; + + if (!helper) { + return null; + } + + return launchHelper(helper, registry); +} + +function launchHelper(helper: string, registry: string) { + // Get the credentials from the helper. + // Parameter for helper is 'get' and registry is passed as input + // The helper should return a JSON object with the keys "Username" and "Secret" + try { + const output = execFileSync(`docker-credential-${helper}`, ["get"], { + input: registry, + }).toString(); + + const { Username: username, Secret: password } = JSON.parse(output); + + return { username, password }; + } catch (err) { + throw new Error(`Failed to get credentials from helper ${helper} for registry ${registry}: ${err}`); + } +} + +// file. +export const getRegistryCredentials = (imageName: string): Credentials => { + const { registry } = parseImageName(imageName); + const dockerConfigFile = path.join(os.homedir(), '.docker', 'config.json'); + + let content: string | undefined; + try { + content = fs.readFileSync(dockerConfigFile, 'utf8'); + } catch (err) { + throw new Error(`No credential file found at ${dockerConfigFile}`); + } + + const dockerConfig: DockerConifg = JSON.parse(content); + + const fromAuths=credentialFromAuths(dockerConfig, registry); + if (fromAuths) { + return fromAuths; + } + const fromCredHelpers=credentialFromCredHelpers(dockerConfig, registry); + if (fromCredHelpers) { + return fromCredHelpers; + } + const fromCredsStore=credentialFromCredsStore(dockerConfig, registry); + if (fromCredsStore) { + return fromCredsStore; + } + throw new Error(`No credentials found for registry ${registry}`); }; // Encode the username and password as base64-encoded basicauth value