Merge branch 'main' into henrymercer/only-request-properties-for-orgs

This commit is contained in:
Henry Mercer
2026-01-27 15:17:20 +00:00
30 changed files with 51651 additions and 42769 deletions

View File

@@ -4,37 +4,122 @@ import * as path from "path";
import test from "ava";
import { scanArtifactsForTokens } from "./artifact-scanner";
import {
GITHUB_PAT_CLASSIC_PATTERN,
isAuthToken,
scanArtifactsForTokens,
TokenType,
} from "./artifact-scanner";
import { getRunnerLogger } from "./logging";
import { getRecordingLogger, LoggedMessage } from "./testing-utils";
import {
checkExpectedLogMessages,
getRecordingLogger,
LoggedMessage,
makeTestToken,
} from "./testing-utils";
test("scanArtifactsForTokens detects GitHub tokens in files", async (t) => {
const logger = getRunnerLogger(true);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "scanner-test-"));
try {
// Create a test file with a fake GitHub token
const testFile = path.join(tempDir, "test.txt");
fs.writeFileSync(
testFile,
"This is a test file with token ghp_1234567890123456789012345678901234AB",
);
const error = await t.throwsAsync(
async () => await scanArtifactsForTokens([testFile], logger),
);
t.regex(
error?.message || "",
/Found 1 potential GitHub token.*Personal Access Token/,
);
t.regex(error?.message || "", /test\.txt/);
} finally {
// Clean up
fs.rmSync(tempDir, { recursive: true, force: true });
}
test("makeTestToken", (t) => {
t.is(makeTestToken().length, 36);
t.is(makeTestToken(255).length, 255);
});
test("isAuthToken", (t) => {
// Undefined for strings that aren't tokens
t.is(isAuthToken("some string"), undefined);
t.is(isAuthToken("ghp_"), undefined);
t.is(isAuthToken("ghp_123"), undefined);
// Token types for strings that are tokens.
t.is(isAuthToken(`ghp_${makeTestToken()}`), TokenType.PersonalAccessClassic);
t.is(isAuthToken(`ghp_${makeTestToken()}`), TokenType.PersonalAccessClassic);
t.is(
isAuthToken(`ghs_${makeTestToken(255)}`),
TokenType.AppInstallationAccess,
);
t.is(
isAuthToken(`github_pat_${makeTestToken(22)}_${makeTestToken(59)}`),
TokenType.PersonalAccessFineGrained,
);
// With a custom pattern set
t.is(
isAuthToken(`ghp_${makeTestToken()}`, [GITHUB_PAT_CLASSIC_PATTERN]),
TokenType.PersonalAccessClassic,
);
t.is(
isAuthToken(`github_pat_${makeTestToken(22)}_${makeTestToken(59)}`, [
GITHUB_PAT_CLASSIC_PATTERN,
]),
undefined,
);
});
const testTokens = [
{
type: TokenType.PersonalAccessClassic,
value: `ghp_${makeTestToken()}`,
checkPattern: "Personal Access Token",
},
{
type: TokenType.PersonalAccessFineGrained,
value:
"github_pat_1234567890ABCDEFGHIJKL_MNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHI",
checkPattern: "Personal Access Token",
},
{
type: TokenType.OAuth,
value: `gho_${makeTestToken()}`,
},
{
type: TokenType.UserToServer,
value: `ghu_${makeTestToken()}`,
},
{
type: TokenType.ServerToServer,
value: `ghs_${makeTestToken()}`,
},
{
type: TokenType.Refresh,
value: `ghr_${makeTestToken()}`,
},
{
type: TokenType.AppInstallationAccess,
value: `ghs_${makeTestToken(255)}`,
},
];
for (const { type, value, checkPattern } of testTokens) {
test(`scanArtifactsForTokens detects GitHub ${type} tokens in files`, async (t) => {
const logMessages = [];
const logger = getRecordingLogger(logMessages, { logToConsole: false });
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "scanner-test-"));
try {
// Create a test file with a fake GitHub token
const testFile = path.join(tempDir, "test.txt");
fs.writeFileSync(testFile, `This is a test file with token ${value}`);
const error = await t.throwsAsync(
async () => await scanArtifactsForTokens([testFile], logger),
);
t.regex(
error?.message || "",
new RegExp(`Found 1 potential GitHub token.*${checkPattern || type}`),
);
t.regex(error?.message || "", /test\.txt/);
checkExpectedLogMessages(t, logMessages, [
"Starting best-effort check",
`Found 1 ${type}`,
]);
} finally {
// Clean up
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
}
test("scanArtifactsForTokens handles files without tokens", async (t) => {
const logger = getRunnerLogger(true);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "scanner-test-"));

View File

@@ -7,33 +7,62 @@ import * as exec from "@actions/exec";
import { Logger } from "./logging";
import { getErrorMessage } from "./util";
/**
* Enumerates known types of GitHub token formats.
*/
export enum TokenType {
PersonalAccessClassic = "Personal Access Token (Classic)",
PersonalAccessFineGrained = "Personal Access Token (Fine-grained)",
OAuth = "OAuth Access Token",
UserToServer = "User-to-Server Token",
ServerToServer = "Server-to-Server Token",
Refresh = "Refresh Token",
AppInstallationAccess = "App Installation Access Token",
}
/** A value of this type associates a token type with its pattern. */
export interface TokenPattern {
type: TokenType;
pattern: RegExp;
}
/** The pattern for PATs (Classic) */
export const GITHUB_PAT_CLASSIC_PATTERN: TokenPattern = {
type: TokenType.PersonalAccessClassic,
pattern: /\bghp_[a-zA-Z0-9]{36}\b/g,
};
/** The pattern for PATs (Fine-grained) */
export const GITHUB_PAT_FINE_GRAINED_PATTERN: TokenPattern = {
type: TokenType.PersonalAccessFineGrained,
pattern: /\bgithub_pat_[a-zA-Z0-9_]+\b/g,
};
/**
* GitHub token patterns to scan for.
* These patterns match various GitHub token formats.
*/
const GITHUB_TOKEN_PATTERNS = [
const GITHUB_TOKEN_PATTERNS: TokenPattern[] = [
GITHUB_PAT_CLASSIC_PATTERN,
GITHUB_PAT_FINE_GRAINED_PATTERN,
{
name: "Personal Access Token",
pattern: /\bghp_[a-zA-Z0-9]{36}\b/g,
},
{
name: "OAuth Access Token",
type: TokenType.OAuth,
pattern: /\bgho_[a-zA-Z0-9]{36}\b/g,
},
{
name: "User-to-Server Token",
type: TokenType.UserToServer,
pattern: /\bghu_[a-zA-Z0-9]{36}\b/g,
},
{
name: "Server-to-Server Token",
type: TokenType.ServerToServer,
pattern: /\bghs_[a-zA-Z0-9]{36}\b/g,
},
{
name: "Refresh Token",
type: TokenType.Refresh,
pattern: /\bghr_[a-zA-Z0-9]{36}\b/g,
},
{
name: "App Installation Access Token",
type: TokenType.AppInstallationAccess,
pattern: /\bghs_[a-zA-Z0-9]{255}\b/g,
},
];
@@ -48,6 +77,24 @@ interface ScanResult {
findings: TokenFinding[];
}
/**
* Checks whether `value` matches any token `patterns`.
* @param value The value to match against.
* @param patterns The patterns to check.
* @returns The type of the first matching pattern, or `undefined` if none match.
*/
export function isAuthToken(
value: string,
patterns: TokenPattern[] = GITHUB_TOKEN_PATTERNS,
) {
for (const { type, pattern } of patterns) {
if (value.match(pattern)) {
return type;
}
}
return undefined;
}
/**
* Scans a file for GitHub tokens.
*
@@ -65,13 +112,13 @@ function scanFileForTokens(
try {
const content = fs.readFileSync(filePath, "utf8");
for (const { name, pattern } of GITHUB_TOKEN_PATTERNS) {
for (const { type, pattern } of GITHUB_TOKEN_PATTERNS) {
const matches = content.match(pattern);
if (matches) {
for (let i = 0; i < matches.length; i++) {
findings.push({ tokenType: name, filePath: relativePath });
findings.push({ tokenType: type, filePath: relativePath });
}
logger.debug(`Found ${matches.length} ${name}(s) in ${relativePath}`);
logger.debug(`Found ${matches.length} ${type}(s) in ${relativePath}`);
}
}

View File

@@ -1,6 +1,6 @@
{
"bundleVersion": "codeql-bundle-v2.23.9",
"cliVersion": "2.23.9",
"priorBundleVersion": "codeql-bundle-v2.23.8",
"priorCliVersion": "2.23.8"
"bundleVersion": "codeql-bundle-v2.24.0",
"cliVersion": "2.24.0",
"priorBundleVersion": "codeql-bundle-v2.23.9",
"priorCliVersion": "2.23.9"
}

View File

@@ -7,7 +7,12 @@ import { KnownLanguage } from "./languages";
import { getRunnerLogger } from "./logging";
import * as startProxyExports from "./start-proxy";
import { parseLanguage } from "./start-proxy";
import { setupTests } from "./testing-utils";
import {
checkExpectedLogMessages,
getRecordingLogger,
makeTestToken,
setupTests,
} from "./testing-utils";
setupTests(test);
@@ -174,6 +179,37 @@ test("getCredentials throws an error when non-printable characters are used", as
}
});
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/");
t.assert(results[0].password?.startsWith("ghp_"));
// A warning should have been logged.
checkExpectedLogMessages(t, loggedMessages, [
"using a GitHub Personal Access Token (PAT), but no username was provided",
]);
});
test("parseLanguage", async (t) => {
// Exact matches
t.deepEqual(parseLanguage("csharp"), KnownLanguage.csharp);

View File

@@ -1,6 +1,7 @@
import * as core from "@actions/core";
import { getApiClient } from "./api-client";
import * as artifactScanner from "./artifact-scanner";
import * as defaults from "./defaults.json";
import { KnownLanguage } from "./languages";
import { Logger } from "./logging";
@@ -62,6 +63,13 @@ export function parseLanguage(language: string): KnownLanguage | undefined {
return undefined;
}
function isPAT(value: string) {
return artifactScanner.isAuthToken(value, [
artifactScanner.GITHUB_PAT_CLASSIC_PATTERN,
artifactScanner.GITHUB_PAT_FINE_GRAINED_PATTERN,
]);
}
const LANGUAGE_TO_REGISTRY_TYPE: Partial<Record<KnownLanguage, string[]>> = {
java: ["maven_repository"],
csharp: ["nuget_feed"],
@@ -161,6 +169,19 @@ export function getCredentials(
);
}
// 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)))
) {
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. ` +
`This may not work correctly. When configuring a private registry using a PAT, select "Username and password" and enter the username of the user ` +
`who generated the PAT.`,
);
}
out.push({
type: e.type,
host: e.host,

View File

@@ -412,3 +412,9 @@ export function createTestConfig(overrides: Partial<Config>): Config {
overrides,
);
}
export function makeTestToken(length: number = 36) {
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
return chars.repeat(Math.ceil(length / chars.length)).slice(0, length);
}