Merge pull request #3563 from github/mbg/private-registry/oidc

Accept OIDC configurations in `start-proxy`
This commit is contained in:
Michael B. Gale
2026-03-13 11:58:36 +00:00
committed by GitHub
22 changed files with 1299 additions and 571 deletions

View File

@@ -12,6 +12,7 @@ import { scanArtifactsForTokens } from "./artifact-scanner";
import { type CodeQL } from "./codeql";
import { Config } from "./config-utils";
import { EnvVar } from "./environment";
import * as json from "./json";
import { Language } from "./languages";
import { Logger, withGroup } from "./logging";
import {
@@ -261,7 +262,7 @@ export function getArtifactSuffix(matrix: string | undefined): string {
if (matrix) {
try {
const matrixObject = JSON.parse(matrix);
if (matrixObject !== null && typeof matrixObject === "object") {
if (json.isObject(matrixObject)) {
for (const matrixKey of Object.keys(matrixObject as object).sort())
suffix += `-${matrixObject[matrixKey]}`;
} else {

View File

@@ -4,6 +4,7 @@ import path from "path";
import Long from "long";
import { DocUrl } from "./doc-url";
import * as json from "./json";
import { Logger } from "./logging";
import type * as sarif from "./sarif";
@@ -189,7 +190,7 @@ export function resolveUriToFile(
typeof location.index !== "number" ||
location.index < 0 ||
location.index >= artifacts.length ||
typeof artifacts[location.index].location !== "object"
!json.isObject(artifacts[location.index].location)
) {
logger.debug(`Ignoring location as index "${location.index}" is invalid`);
return undefined;

38
src/json/index.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Represents a value we have obtained from parsing JSON which we know is an object,
* and expect to be of some type `T` which has not yet been validated.
*/
export type UnvalidatedObject<T> = { [P in keyof T]?: unknown };
/** Represents a value we have obtained from parsing JSON which we know is an array. */
export type UnvalidatedArray = unknown[];
/**
* Attempts to parse `data` as JSON. This function does not perform any validation and will therefore
* return a value of an `unknown` type if successful. Throws if `data` is not valid JSON.
*/
export function parseString(data: string): unknown {
return JSON.parse(data) as unknown;
}
/** Asserts that `value` is an object, which is not yet validated, but expected to be of type `T`. */
export function isObject<T>(value: unknown): value is UnvalidatedObject<T> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Asserts that `value` is an array, which is not yet validated. */
export function isArray(value: unknown): value is UnvalidatedArray {
return Array.isArray(value);
}
/** Asserts that `value` is a string. */
export function isString(value: unknown): value is string {
return typeof value === "string";
}
/** Asserts that `value` is either a string or undefined. */
export function isStringOrUndefined(
value: unknown,
): value is string | undefined {
return value === undefined || isString(value);
}

View File

@@ -19,6 +19,7 @@ import {
getWorkflowRunID,
} from "../actions-util";
import { type CodeQL } from "../codeql";
import * as json from "../json";
import { Logger } from "../logging";
import {
DiskUsage,
@@ -149,8 +150,7 @@ export async function getOverlayStatus(
const contents = await fs.promises.readFile(statusFile, "utf-8");
const parsed: unknown = JSON.parse(contents);
if (
typeof parsed !== "object" ||
parsed === null ||
!json.isObject<OverlayStatus>(parsed) ||
typeof parsed["attemptedToBuildOverlayBaseDatabase"] !== "boolean" ||
typeof parsed["builtOverlayBaseDatabase"] !== "boolean"
) {

View File

@@ -124,125 +124,110 @@ const mixedCredentials = [
{ type: "git_source", host: "github.com/github", token: "mno" },
];
test.serial(
"getCredentials prefers registriesCredentials over registrySecrets",
async (t) => {
const registryCredentials = Buffer.from(
JSON.stringify([
{ type: "npm_registry", host: "npm.pkg.github.com", token: "abc" },
]),
).toString("base64");
const registrySecrets = JSON.stringify([
{ type: "npm_registry", host: "registry.npmjs.org", token: "def" },
]);
test("getCredentials prefers registriesCredentials over registrySecrets", async (t) => {
const registryCredentials = Buffer.from(
JSON.stringify([
{ type: "npm_registry", host: "npm.pkg.github.com", token: "abc" },
]),
).toString("base64");
const registrySecrets = JSON.stringify([
{ type: "npm_registry", host: "registry.npmjs.org", token: "def" },
]);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
registrySecrets,
registryCredentials,
undefined,
);
t.is(credentials.length, 1);
t.is(credentials[0].host, "npm.pkg.github.com");
},
);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
registrySecrets,
registryCredentials,
undefined,
);
t.is(credentials.length, 1);
t.is(credentials[0].host, "npm.pkg.github.com");
});
test.serial(
"getCredentials throws an error when configurations are not an array",
async (t) => {
const registryCredentials = Buffer.from(
JSON.stringify({ type: "npm_registry", token: "abc" }),
).toString("base64");
test("getCredentials throws an error when configurations are not an array", async (t) => {
const registryCredentials = Buffer.from(
JSON.stringify({ type: "npm_registry", token: "abc" }),
).toString("base64");
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
registryCredentials,
undefined,
),
{
message:
"Expected credentials data to be an array of configurations, but it is not.",
},
);
});
test("getCredentials throws error when credential is not an object", async (t) => {
const testCredentials = [["foo"], [null]].map(toEncodedJSON);
for (const testCredential of testCredentials) {
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
registryCredentials,
testCredential,
undefined,
),
{
message:
"Expected credentials data to be an array of configurations, but it is not.",
message: "Invalid credentials - must be an object",
},
);
},
);
}
});
test.serial(
"getCredentials throws error when credential is not an object",
async (t) => {
const testCredentials = [["foo"], [null]].map(toEncodedJSON);
test("getCredentials throws error when credential is missing type", async (t) => {
const testCredentials = [[{ token: "abc", url: "https://localhost" }]].map(
toEncodedJSON,
);
for (const testCredential of testCredentials) {
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
testCredential,
undefined,
),
{
message: "Invalid credentials - must be an object",
},
);
}
},
);
test.serial(
"getCredentials throws error when credential is missing type",
async (t) => {
const testCredentials = [[{ token: "abc", url: "https://localhost" }]].map(
toEncodedJSON,
for (const testCredential of testCredentials) {
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
testCredential,
undefined,
),
{
message: "Invalid credentials - must have a type",
},
);
}
});
for (const testCredential of testCredentials) {
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
testCredential,
undefined,
),
{
message: "Invalid credentials - must have a type",
},
);
}
},
);
test("getCredentials throws error when credential missing host and url", async (t) => {
const testCredentials = [
[{ type: "npm_registry", token: "abc" }],
[{ type: "npm_registry", token: "abc", host: null }],
[{ type: "npm_registry", token: "abc", url: null }],
].map(toEncodedJSON);
test.serial(
"getCredentials throws error when credential missing host and url",
async (t) => {
const testCredentials = [
[{ type: "npm_registry", token: "abc" }],
[{ type: "npm_registry", token: "abc", host: null }],
[{ type: "npm_registry", token: "abc", url: null }],
].map(toEncodedJSON);
for (const testCredential of testCredentials) {
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
testCredential,
undefined,
),
{
message: "Invalid credentials - must specify host or url",
},
);
}
});
for (const testCredential of testCredentials) {
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
testCredential,
undefined,
),
{
message: "Invalid credentials - must specify host or url",
},
);
}
},
);
test.serial("getCredentials filters by language when specified", async (t) => {
test("getCredentials filters by language when specified", async (t) => {
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
@@ -253,145 +238,270 @@ test.serial("getCredentials filters by language when specified", async (t) => {
t.is(credentials[0].type, "maven_repository");
});
test.serial(
"getCredentials returns all for a language when specified",
async (t) => {
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
toEncodedJSON(mixedCredentials),
KnownLanguage.go,
);
t.is(credentials.length, 2);
test("getCredentials returns all for a language when specified", async (t) => {
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
toEncodedJSON(mixedCredentials),
KnownLanguage.go,
);
t.is(credentials.length, 2);
const credentialsTypes = credentials.map((c) => c.type);
t.assert(credentialsTypes.includes("goproxy_server"));
t.assert(credentialsTypes.includes("git_source"));
},
);
const credentialsTypes = credentials.map((c) => c.type);
t.assert(credentialsTypes.includes("goproxy_server"));
t.assert(credentialsTypes.includes("git_source"));
});
test.serial(
"getCredentials returns all credentials when no language specified",
async (t) => {
const credentialsInput = toEncodedJSON(mixedCredentials);
test("getCredentials returns all credentials when no language specified", async (t) => {
const credentialsInput = toEncodedJSON(mixedCredentials);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
undefined,
);
t.is(credentials.length, mixedCredentials.length);
},
);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
undefined,
);
t.is(credentials.length, mixedCredentials.length);
});
test.serial(
"getCredentials throws an error when non-printable characters are used",
async (t) => {
const invalidCredentials = [
{ type: "nuget_feed", host: "1nuget.pkg.github.com", token: "abc\u0000" }, // Non-printable character in token
{ type: "nuget_feed", host: "2nuget.pkg.github.com\u0001" }, // Non-printable character in host
test("getCredentials throws an error when non-printable characters are used", async (t) => {
const invalidCredentials: startProxyExports.RawCredential[] = [
{ type: "nuget_feed", host: "1nuget.pkg.github.com", token: "abc\u0000" }, // Non-printable character in token
{ type: "nuget_feed", host: "2nuget.pkg.github.com\u0001" }, // Non-printable character in host
{
type: "nuget_feed",
host: "3nuget.pkg.github.com",
password: "ghi\u0002",
}, // Non-printable character in password
{
type: "nuget_feed",
host: "4nuget.pkg.github.com",
token: "ghi\x00",
}, // Non-printable character in token
];
for (const invalidCredential of invalidCredentials) {
const credentialsInput = toEncodedJSON([invalidCredential]);
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
undefined,
),
{
type: "nuget_feed",
host: "3nuget.pkg.github.com",
password: "ghi\u0002",
}, // Non-printable character in password
{
type: "nuget_feed",
host: "4nuget.pkg.github.com",
password: "ghi\x00",
}, // Non-printable character in password
];
for (const invalidCredential of invalidCredentials) {
const credentialsInput = Buffer.from(
JSON.stringify([invalidCredential]),
).toString("base64");
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
undefined,
),
{
message:
"Invalid credentials - fields must contain only printable characters",
},
);
}
},
);
test.serial(
"getCredentials logs a warning when a PAT is used without a username",
async (t) => {
const loggedMessages = [];
const logger = getRecordingLogger(loggedMessages);
const likelyWrongCredentials = toEncodedJSON([
{
type: "git_server",
host: "https://github.com/",
password: `ghp_${makeTestToken()}`,
message:
"Invalid credentials - fields must contain only printable characters",
},
]);
const results = startProxyExports.getCredentials(
logger,
undefined,
likelyWrongCredentials,
undefined,
);
}
});
// The configuration should be accepted, despite the likely problem.
t.assert(results);
t.is(results.length, 1);
t.is(results[0].type, "git_server");
t.is(results[0].host, "https://github.com/");
const validAzureCredential: startProxyExports.AzureConfig = {
tenant_id: "12345678-1234-1234-1234-123456789012",
client_id: "abcdef01-2345-6789-abcd-ef0123456789",
};
const validAwsCredential: startProxyExports.AWSConfig = {
aws_region: "us-east-1",
account_id: "123456789012",
role_name: "MY_ROLE",
domain: "MY_DOMAIN",
domain_owner: "987654321098",
audience: "custom-audience",
};
const validJFrogCredential: startProxyExports.JFrogConfig = {
jfrog_oidc_provider_name: "MY_PROVIDER",
audience: "jfrog-audience",
identity_mapping_name: "my-mapping",
};
test("getCredentials throws an error when non-printable characters are used for Azure OIDC", (t) => {
for (const key of Object.keys(validAzureCredential)) {
const invalidAzureCredential = {
...validAzureCredential,
[key]: "123\x00",
};
const invalidCredential: startProxyExports.RawCredential = {
type: "nuget_feed",
host: `${key}.nuget.pkg.github.com`,
...invalidAzureCredential,
};
const credentialsInput = toEncodedJSON([invalidCredential]);
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
undefined,
),
{
message:
"Invalid credentials - fields must contain only printable characters",
},
);
}
});
test("getCredentials throws an error when non-printable characters are used for AWS OIDC", (t) => {
for (const key of Object.keys(validAwsCredential)) {
const invalidAwsCredential = {
...validAwsCredential,
[key]: "123\x00",
};
const invalidCredential: startProxyExports.RawCredential = {
type: "nuget_feed",
host: `${key}.nuget.pkg.github.com`,
...invalidAwsCredential,
};
const credentialsInput = toEncodedJSON([invalidCredential]);
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
undefined,
),
{
message:
"Invalid credentials - fields must contain only printable characters",
},
);
}
});
test("getCredentials throws an error when non-printable characters are used for JFrog OIDC", (t) => {
for (const key of Object.keys(validJFrogCredential)) {
const invalidJFrogCredential = {
...validJFrogCredential,
[key]: "123\x00",
};
const invalidCredential: startProxyExports.RawCredential = {
type: "nuget_feed",
host: `${key}.nuget.pkg.github.com`,
...invalidJFrogCredential,
};
const credentialsInput = toEncodedJSON([invalidCredential]);
t.throws(
() =>
startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
undefined,
),
{
message:
"Invalid credentials - fields must contain only printable characters",
},
);
}
});
test("getCredentials accepts OIDC configurations", (t) => {
const oidcConfigurations = [
{
type: "nuget_feed",
host: "azure.pkg.github.com",
...validAzureCredential,
},
{
type: "nuget_feed",
host: "aws.pkg.github.com",
...validAwsCredential,
},
{
type: "nuget_feed",
host: "jfrog.pkg.github.com",
...validJFrogCredential,
},
];
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
toEncodedJSON(oidcConfigurations),
KnownLanguage.csharp,
);
t.is(credentials.length, 3);
t.assert(credentials.every((c) => c.type === "nuget_feed"));
t.assert(credentials.some((c) => startProxyExports.isAzureConfig(c)));
t.assert(credentials.some((c) => startProxyExports.isAWSConfig(c)));
t.assert(credentials.some((c) => startProxyExports.isJFrogConfig(c)));
});
test("getCredentials logs a warning when a PAT is used without a username", async (t) => {
const loggedMessages = [];
const logger = getRecordingLogger(loggedMessages);
const likelyWrongCredentials = toEncodedJSON([
{
type: "git_server",
host: "https://github.com/",
password: `ghp_${makeTestToken()}`,
},
]);
const results = startProxyExports.getCredentials(
logger,
undefined,
likelyWrongCredentials,
undefined,
);
// The configuration should be accepted, despite the likely problem.
t.assert(results);
t.is(results.length, 1);
t.is(results[0].type, "git_server");
t.is(results[0].host, "https://github.com/");
if (startProxyExports.isUsernamePassword(results[0])) {
t.assert(results[0].password?.startsWith("ghp_"));
} else {
t.fail("Expected a `UsernamePassword`-based credential.");
}
// A warning should have been logged.
checkExpectedLogMessages(t, loggedMessages, [
"using a GitHub Personal Access Token (PAT), but no username was provided",
]);
},
);
// A warning should have been logged.
checkExpectedLogMessages(t, loggedMessages, [
"using a GitHub Personal Access Token (PAT), but no username was provided",
]);
});
test.serial(
"getCredentials returns all credentials for Actions when using LANGUAGE_TO_REGISTRY_TYPE",
async (t) => {
const credentialsInput = toEncodedJSON(mixedCredentials);
test("getCredentials returns all credentials for Actions when using LANGUAGE_TO_REGISTRY_TYPE", async (t) => {
const credentialsInput = toEncodedJSON(mixedCredentials);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
KnownLanguage.actions,
false,
);
t.is(credentials.length, mixedCredentials.length);
},
);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
KnownLanguage.actions,
false,
);
t.is(credentials.length, mixedCredentials.length);
});
test.serial(
"getCredentials returns no credentials for Actions when using NEW_LANGUAGE_TO_REGISTRY_TYPE",
async (t) => {
const credentialsInput = toEncodedJSON(mixedCredentials);
test("getCredentials returns no credentials for Actions when using NEW_LANGUAGE_TO_REGISTRY_TYPE", async (t) => {
const credentialsInput = toEncodedJSON(mixedCredentials);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
KnownLanguage.actions,
true,
);
t.deepEqual(credentials, []);
},
);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
KnownLanguage.actions,
true,
);
t.deepEqual(credentials, []);
});
test.serial("parseLanguage", async (t) => {
test("parseLanguage", async (t) => {
// Exact matches
t.deepEqual(parseLanguage("csharp"), KnownLanguage.csharp);
t.deepEqual(parseLanguage("cpp"), KnownLanguage.cpp);
@@ -524,40 +634,6 @@ test.serial("getDownloadUrl returns matching release asset", async (t) => {
});
});
test.serial("credentialToStr - hides passwords", (t) => {
const secret = "password123";
const credential = {
type: "maven_credential",
password: secret,
url: "https://localhost",
};
const str = startProxyExports.credentialToStr(credential);
t.false(str.includes(secret));
t.is(
"Type: maven_credential; Host: undefined; Url: https://localhost Username: undefined; Password: true; Token: false",
str,
);
});
test.serial("credentialToStr - hides tokens", (t) => {
const secret = "password123";
const credential = {
type: "maven_credential",
token: secret,
url: "https://localhost",
};
const str = startProxyExports.credentialToStr(credential);
t.false(str.includes(secret));
t.is(
"Type: maven_credential; Host: undefined; Url: https://localhost Username: undefined; Password: false; Token: true",
str,
);
});
test.serial(
"getSafeErrorMessage - returns actual message for `StartProxyError`",
(t) => {

View File

@@ -17,13 +17,26 @@ import {
Feature,
FeatureEnablement,
} from "./feature-flags";
import * as json from "./json";
import { KnownLanguage } from "./languages";
import { Logger } from "./logging";
import {
Address,
RawCredential,
Registry,
Credential,
AuthConfig,
isToken,
isAzureConfig,
Token,
UsernamePassword,
AzureConfig,
isAWSConfig,
AWSConfig,
isJFrogConfig,
JFrogConfig,
isUsernamePassword,
hasUsername,
RawCredential,
} from "./start-proxy/types";
import {
ActionName,
@@ -255,13 +268,19 @@ const NEW_LANGUAGE_TO_REGISTRY_TYPE: Required<RegistryMapping> = {
*
* @throws A `ConfigurationError` if the `Registry` value contains neither a `url` or `host` field.
*/
function getRegistryAddress(registry: Partial<Registry>): Address {
if (isDefined(registry.url)) {
function getRegistryAddress(
registry: json.UnvalidatedObject<Registry>,
): Address {
if (
isDefined(registry.url) &&
json.isString(registry.url) &&
json.isStringOrUndefined(registry.host)
) {
return {
url: registry.url,
host: registry.host,
};
} else if (isDefined(registry.host)) {
} else if (isDefined(registry.host) && json.isString(registry.host)) {
return {
url: undefined,
host: registry.host,
@@ -274,6 +293,75 @@ function getRegistryAddress(registry: Partial<Registry>): Address {
}
}
/** Extracts an `AuthConfig` value from `config`. */
export function getAuthConfig(
config: json.UnvalidatedObject<AuthConfig>,
): AuthConfig {
// Start by checking for the OIDC configurations, since they have required properties
// which we can use to identify them.
if (isAzureConfig(config)) {
return {
tenant_id: config.tenant_id,
client_id: config.client_id,
} satisfies AzureConfig;
} else if (isAWSConfig(config)) {
return {
aws_region: config.aws_region,
account_id: config.account_id,
role_name: config.role_name,
domain: config.domain,
domain_owner: config.domain_owner,
audience: config.audience,
} satisfies AWSConfig;
} else if (isJFrogConfig(config)) {
return {
jfrog_oidc_provider_name: config.jfrog_oidc_provider_name,
identity_mapping_name: config.identity_mapping_name,
audience: config.audience,
} satisfies JFrogConfig;
} else if (isToken(config)) {
// There are three scenarios for non-OIDC authentication based on the registry type:
//
// 1. `username`+`token`
// 2. A `token` that combines the username and actual token, separated by ':'.
// 3. `username`+`password`
//
// In all three cases, all fields are optional. If the `token` field is present,
// we accept the configuration as a `Token` typed configuration, with the `token`
// value and an optional `username`. Otherwise, we accept the configuration
// typed as `UsernamePassword` (in the `else` clause below) with optional
// username and password. I.e. a private registry type that uses 1. or 2.,
// but has no `token` configured, will get accepted as `UsernamePassword` here.
if (isDefined(config.token)) {
// Mask token to reduce chance of accidental leakage in logs, if we have one.
core.setSecret(config.token);
}
return { username: config.username, token: config.token } satisfies Token;
} else {
let username: string | undefined = undefined;
let password: string | undefined = undefined;
// Both "username" and "password" are optional. If we have reached this point, we need
// to validate which of them are present and that they have the correct type if so.
if ("password" in config && json.isString(config.password)) {
// Mask password to reduce chance of accidental leakage in logs, if we have one.
core.setSecret(config.password);
password = config.password;
}
if ("username" in config && json.isString(config.username)) {
username = config.username;
}
// Return the `UsernamePassword` object. Both username and password may be undefined.
return {
username,
password,
} satisfies UsernamePassword;
}
}
// getCredentials returns registry credentials from action inputs.
// It prefers `registries_credentials` over `registry_secrets`.
// If neither is set, it returns an empty array.
@@ -304,9 +392,9 @@ export function getCredentials(
}
// Parse and validate the credentials
let parsed: RawCredential[];
let parsed: unknown;
try {
parsed = JSON.parse(credentialsStr) as RawCredential[];
parsed = json.parseString(credentialsStr);
} catch {
// Don't log the error since it might contain sensitive information.
logger.error("Failed to parse the credentials data.");
@@ -314,7 +402,7 @@ export function getCredentials(
}
// Check that the parsed data is indeed an array.
if (!Array.isArray(parsed)) {
if (!json.isArray(parsed)) {
throw new ConfigurationError(
"Expected credentials data to be an array of configurations, but it is not.",
);
@@ -322,23 +410,17 @@ export function getCredentials(
const out: Credential[] = [];
for (const e of parsed) {
if (e === null || typeof e !== "object") {
if (e === null || !json.isObject<RawCredential>(e)) {
throw new ConfigurationError("Invalid credentials - must be an object");
}
// The configuration must have a type.
if (!isDefined(e.type)) {
if (!isDefined(e.type) || !json.isString(e.type)) {
throw new ConfigurationError("Invalid credentials - must have a type");
}
// Mask credentials to reduce chance of accidental leakage in logs.
if (isDefined(e.password)) {
core.setSecret(e.password);
}
if (isDefined(e.token)) {
core.setSecret(e.token);
}
const authConfig = getAuthConfig(e);
const address = getRegistryAddress(e);
// Filter credentials based on language if specified. `type` is the registry type.
@@ -354,24 +436,25 @@ export function getCredentials(
return str ? /^[\x20-\x7E]*$/.test(str) : true;
};
if (
!isPrintable(e.type) ||
!isPrintable(e.host) ||
!isPrintable(e.url) ||
!isPrintable(e.username) ||
!isPrintable(e.password) ||
!isPrintable(e.token)
) {
throw new ConfigurationError(
"Invalid credentials - fields must contain only printable characters",
);
// Ensure that all string fields only contain printable characters.
for (const key of Object.keys(e)) {
const val = e[key];
if (typeof val === "string" && !isPrintable(val)) {
throw new ConfigurationError(
"Invalid credentials - fields must contain only printable characters",
);
}
}
// If the password or token looks like a GitHub PAT, warn if no username is configured.
if (
!isDefined(e.username) &&
((isDefined(e.password) && isPAT(e.password)) ||
(isDefined(e.token) && isPAT(e.token)))
((!hasUsername(authConfig) || !isDefined(authConfig.username)) &&
isUsernamePassword(authConfig) &&
isDefined(authConfig.password) &&
isPAT(authConfig.password)) ||
(isToken(authConfig) &&
isDefined(authConfig.token) &&
isPAT(authConfig.token))
) {
logger.warning(
`A ${e.type} private registry is configured for ${e.host || e.url} using a GitHub Personal Access Token (PAT), but no username was provided. ` +
@@ -382,9 +465,7 @@ export function getCredentials(
out.push({
type: e.type,
username: e.username,
password: e.password,
token: e.token,
...authConfig,
...address,
});
}
@@ -498,17 +579,6 @@ export async function getDownloadUrl(
};
}
/**
* Pretty-prints a `Credential` value to a string, but hides the actual password or token values.
*
* @param c The credential to convert to a string.
*/
export function credentialToStr(c: Credential): string {
return `Type: ${c.type}; Host: ${c.host}; Url: ${c.url} Username: ${
c.username
}; Password: ${c.password !== undefined}; Token: ${c.token !== undefined}`;
}
/**
* Attempts to download a file from `url` into the toolcache.
*

View File

@@ -0,0 +1,136 @@
import test from "ava";
import { setupTests } from "../testing-utils";
import * as types from "./types";
setupTests(test);
const validAzureCredential: types.AzureConfig = {
tenant_id: "12345678-1234-1234-1234-123456789012",
client_id: "abcdef01-2345-6789-abcd-ef0123456789",
};
const validAwsCredential: types.AWSConfig = {
aws_region: "us-east-1",
account_id: "123456789012",
role_name: "MY_ROLE",
domain: "MY_DOMAIN",
domain_owner: "987654321098",
audience: "custom-audience",
};
const validJFrogCredential: types.JFrogConfig = {
jfrog_oidc_provider_name: "MY_PROVIDER",
audience: "jfrog-audience",
identity_mapping_name: "my-mapping",
};
test("credentialToStr - pretty-prints valid username+password configurations", (t) => {
const secret = "password123";
const credential: types.Credential = {
type: "maven_credential",
username: "user",
password: secret,
url: "https://localhost",
};
const str = types.credentialToStr(credential);
t.false(str.includes(secret));
t.is(
"Type: maven_credential; Url: https://localhost; Username: user; Password: ***;",
str,
);
});
test("credentialToStr - pretty-prints valid username+token configurations", (t) => {
const secret = "password123";
const credential: types.Credential = {
type: "maven_credential",
username: "user",
token: secret,
url: "https://localhost",
};
const str = types.credentialToStr(credential);
t.false(str.includes(secret));
t.is(
"Type: maven_credential; Url: https://localhost; Username: user; Token: ***;",
str,
);
});
test("credentialToStr - pretty-prints valid Azure OIDC configurations", (t) => {
const credential: types.Credential = {
type: "maven_credential",
url: "https://localhost",
...validAzureCredential,
};
const str = types.credentialToStr(credential);
t.is(
"Type: maven_credential; Url: https://localhost; Tenant: 12345678-1234-1234-1234-123456789012; Client: abcdef01-2345-6789-abcd-ef0123456789;",
str,
);
});
test("credentialToStr - pretty-prints valid AWS OIDC configurations", (t) => {
const credential: types.Credential = {
type: "maven_credential",
url: "https://localhost",
...validAwsCredential,
};
const str = types.credentialToStr(credential);
t.is(
"Type: maven_credential; Url: https://localhost; AWS Region: us-east-1; AWS Account: 123456789012; AWS Role: MY_ROLE; AWS Domain: MY_DOMAIN; AWS Domain Owner: 987654321098; AWS Audience: custom-audience;",
str,
);
});
test("credentialToStr - pretty-prints valid JFrog OIDC configurations", (t) => {
const credential: types.Credential = {
type: "maven_credential",
url: "https://localhost",
...validJFrogCredential,
};
const str = types.credentialToStr(credential);
t.is(
"Type: maven_credential; Url: https://localhost; JFrog Provider: MY_PROVIDER; JFrog Identity Mapping: my-mapping; JFrog Audience: jfrog-audience;",
str,
);
});
test("credentialToStr - hides passwords", (t) => {
const secret = "password123";
const credential = {
type: "maven_credential",
password: secret,
url: "https://localhost",
};
const str = types.credentialToStr(credential);
t.false(str.includes(secret));
t.is("Type: maven_credential; Url: https://localhost; Password: ***;", str);
});
test("credentialToStr - hides tokens", (t) => {
const secret = "password123";
const credential = {
type: "maven_credential",
token: secret,
url: "https://localhost",
};
const str = types.credentialToStr(credential);
t.false(str.includes(secret));
t.is("Type: maven_credential; Url: https://localhost; Token: ***;", str);
});

View File

@@ -1,22 +1,211 @@
import type { UnvalidatedObject } from "../json";
import * as json from "../json";
import { isDefined } from "../util";
/**
* After parsing configurations from JSON, we don't know whether all the keys we expect are
* present or not. This type is used to represent such values, which we expect to be
* `Credential` values, but haven't validated yet.
*/
export type RawCredential = Partial<Credential>;
export type RawCredential = UnvalidatedObject<Credential>;
/** Usernames may be present for both authentication with tokens or passwords. */
export type Username = {
/** The username needed to authenticate to the package registry, if any. */
username?: string;
};
/** Decides whether `config` has a username. */
export function hasUsername(config: AuthConfig): config is Username {
return "username" in config;
}
/**
* Fields expected for authentication based on a username and password.
* Both username and password are optional.
*/
export type UsernamePassword = {
/** The password needed to authenticate to the package registry, if any. */
password?: string;
} & Username;
/** Decides whether `config` is based on a username and password. */
export function isUsernamePassword(
config: AuthConfig,
): config is UsernamePassword {
return hasUsername(config) && "password" in config;
}
/**
* Fields expected for token-based authentication.
* Both username and token are optional.
*/
export type Token = {
/** The token needed to authenticate to the package registry, if any. */
token?: string;
} & Username;
/** Decides whether `config` is token-based. */
export function isToken(
config: UnvalidatedObject<AuthConfig>,
): config is Token {
// The "username" field is optional, but should be a string if present.
if ("username" in config && !json.isStringOrUndefined(config.username)) {
return false;
}
// The "token" field is required, and must be a string or undefined.
return "token" in config && json.isStringOrUndefined(config.token);
}
/** Configuration for Azure OIDC. */
export type AzureConfig = { tenant_id: string; client_id: string };
/** Decides whether `config` is an Azure OIDC configuration. */
export function isAzureConfig(
config: UnvalidatedObject<AuthConfig>,
): config is AzureConfig {
return (
"tenant_id" in config &&
"client_id" in config &&
isDefined(config.tenant_id) &&
isDefined(config.client_id) &&
json.isString(config.tenant_id) &&
json.isString(config.client_id)
);
}
/** Configuration for AWS OIDC. */
export type AWSConfig = {
aws_region: string;
account_id: string;
role_name: string;
domain: string;
domain_owner: string;
audience?: string;
};
/** Decides whether `config` is an AWS OIDC configuration. */
export function isAWSConfig(
config: UnvalidatedObject<AuthConfig>,
): config is AWSConfig {
// All of these properties are required.
const requiredProperties = [
"aws_region",
"account_id",
"role_name",
"domain",
"domain_owner",
];
for (const property of requiredProperties) {
if (
!(property in config) ||
!isDefined(config[property]) ||
!json.isString(config[property])
) {
return false;
}
}
// The "audience" field is optional, but should be a string if present.
if ("audience" in config && !json.isStringOrUndefined(config.audience)) {
return false;
}
return true;
}
/** Configuration for JFrog OIDC. */
export type JFrogConfig = {
jfrog_oidc_provider_name: string;
audience?: string;
identity_mapping_name?: string;
};
/** Decides whether `config` is a JFrog OIDC configuration. */
export function isJFrogConfig(
config: UnvalidatedObject<AuthConfig>,
): config is JFrogConfig {
// The "audience" and "identity_mapping_name" fields are optional, but should be strings if present.
if ("audience" in config && !json.isStringOrUndefined(config.audience)) {
return false;
}
if (
"identity_mapping_name" in config &&
!json.isStringOrUndefined(config.identity_mapping_name)
) {
return false;
}
return (
"jfrog_oidc_provider_name" in config &&
isDefined(config.jfrog_oidc_provider_name) &&
json.isString(config.jfrog_oidc_provider_name)
);
}
/** Represents all supported OIDC configurations. */
export type OIDC = AzureConfig | AWSConfig | JFrogConfig;
/** All authentication-related fields. */
export type AuthConfig = UsernamePassword | Token | OIDC;
/**
* A package registry configuration includes identifying information as well as
* authentication credentials.
*/
export type Credential = {
/** The username needed to authenticate to the package registry, if any. */
username?: string;
/** The password needed to authenticate to the package registry, if any. */
password?: string;
/** The token needed to authenticate to the package registry, if any. */
token?: string;
} & Registry;
export type Credential = AuthConfig & Registry;
/**
* Pretty-prints a `Credential` value to a string, but hides the actual password or token values.
*
* @param credential The credential to convert to a string.
*/
export function credentialToStr(credential: Credential): string {
let result: string = `Type: ${credential.type};`;
const appendIfDefined = (name: string, val: string | undefined) => {
if (isDefined(val)) {
result += ` ${name}: ${val};`;
}
};
appendIfDefined("Url", credential.url);
appendIfDefined("Host", credential.host);
if (hasUsername(credential)) {
appendIfDefined("Username", credential.username);
}
if ("password" in credential) {
appendIfDefined(
"Password",
isDefined(credential.password) ? "***" : undefined,
);
}
if (isToken(credential)) {
appendIfDefined("Token", isDefined(credential.token) ? "***" : undefined);
}
if (isAzureConfig(credential)) {
appendIfDefined("Tenant", credential.tenant_id);
appendIfDefined("Client", credential.client_id);
} else if (isAWSConfig(credential)) {
appendIfDefined("AWS Region", credential.aws_region);
appendIfDefined("AWS Account", credential.account_id);
appendIfDefined("AWS Role", credential.role_name);
appendIfDefined("AWS Domain", credential.domain);
appendIfDefined("AWS Domain Owner", credential.domain_owner);
appendIfDefined("AWS Audience", credential.audience);
} else if (isJFrogConfig(credential)) {
appendIfDefined("JFrog Provider", credential.jfrog_oidc_provider_name);
appendIfDefined("JFrog Identity Mapping", credential.identity_mapping_name);
appendIfDefined("JFrog Audience", credential.audience);
}
return result;
}
/** A package registry is identified by its type and address. */
export type Registry = {

View File

@@ -14,6 +14,7 @@ import type { CodeQL, VersionInfo } from "./codeql";
import type { Pack } from "./config/db-config";
import type { Config } from "./config-utils";
import { EnvVar } from "./environment";
import * as json from "./json";
import { Language } from "./languages";
import { Logger } from "./logging";
@@ -603,19 +604,15 @@ export class HTTPError extends Error {
export class ConfigurationError extends Error {}
export function asHTTPError(arg: any): HTTPError | undefined {
if (
typeof arg !== "object" ||
arg === null ||
typeof arg.message !== "string"
) {
if (!json.isObject<any>(arg) || !json.isString(arg.message)) {
return undefined;
}
if (Number.isInteger(arg.status)) {
return new HTTPError(arg.message as string, arg.status as number);
return new HTTPError(arg.message, arg.status as number);
}
// See https://github.com/actions/toolkit/blob/acb230b99a46ed33a3f04a758cd68b47b9a82908/packages/tool-cache/src/tool-cache.ts#L19
if (Number.isInteger(arg.httpStatusCode)) {
return new HTTPError(arg.message as string, arg.httpStatusCode as number);
return new HTTPError(arg.message, arg.httpStatusCode as number);
}
return undefined;
}