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
Prev Previous commit
Next Next commit
Add simple artifact scanner for tests only
  • Loading branch information
henrymercer committed Dec 17, 2025
commit 5459b98ca041d9542e6bf312cd9f6127762543fe
80 changes: 12 additions & 68 deletions src/artifact-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as path from "path";

import test from "ava";

import { scanArtifactsForTokens } from "../.github/workflows/artifact-scanner/artifact-scanner";
import { scanArtifactsForTokens } from "./artifact-scanner";
import { getRunnerLogger } from "./logging";

test("scanArtifactsForTokens detects GitHub tokens in files", async (t) => {
Expand All @@ -19,12 +19,15 @@ test("scanArtifactsForTokens detects GitHub tokens in files", async (t) => {
"This is a test file with token ghp_1234567890123456789012345678901234AB",
);

const result = await scanArtifactsForTokens([testFile], logger);
const error = await t.throwsAsync(
async () => await scanArtifactsForTokens([testFile], logger),
);

t.is(result.scannedFiles, 1);
t.is(result.findings.length, 1);
t.is(result.findings[0].tokenType, "Personal Access Token");
t.is(result.findings[0].filePath, "test.txt");
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 });
Expand All @@ -43,70 +46,11 @@ test("scanArtifactsForTokens handles files without tokens", async (t) => {
"This is a test file without any sensitive data",
);

const result = await scanArtifactsForTokens([testFile], logger);

t.is(result.scannedFiles, 1);
t.is(result.findings.length, 0);
} finally {
// Clean up
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

test("scanArtifactsForTokens skips binary files", async (t) => {
const logger = getRunnerLogger(true);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "scanner-test-"));

try {
// Create a binary file (we'll just use a simple zip for this test)
const zipFile = path.join(tempDir, "test.zip");
fs.writeFileSync(zipFile, Buffer.from([0x50, 0x4b, 0x03, 0x04])); // ZIP header

const result = await scanArtifactsForTokens([zipFile], logger);

// The zip file itself should be counted but not scanned for tokens
t.is(result.findings.length, 0);
await t.notThrowsAsync(
async () => await scanArtifactsForTokens([testFile], logger),
);
} finally {
// Clean up
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

test("scanArtifactsForTokens detects tokens in debug artifacts zip", async (t) => {
const logger = getRunnerLogger(true);
const testZipPath = path.join(
__dirname,
"..",
"..",
"..",
"src",
"testdata",
"debug-artifacts-with-fake-token.zip",
);

const result = await scanArtifactsForTokens([testZipPath], logger);

t.true(result.scannedFiles > 0, "Should have scanned files");
t.true(
result.findings.length > 0,
"Should have found tokens in the test zip",
);

// Check that the token types are tracked
const serverToServerFindings = result.findings.filter(
(f) => f.tokenType === "Server-to-Server Token",
);
t.is(
serverToServerFindings.length,
1,
"Should have found exactly 1 Server-to-Server Token",
);

// Check that the path includes the nested structure
const expectedPath =
"debug-artifacts-with-fake-token.zip/debug-artifacts-with-test-token/my-db-java-partial.zip/my-db-java-partial/trap/java/invocations/kotlin.9017231652989744319.trap";
t.true(
result.findings.some((f) => f.filePath === expectedPath),
`Expected to find token at ${expectedPath}, but found: ${result.findings.map((f) => f.filePath).join(", ")}`,
);
});
134 changes: 47 additions & 87 deletions src/artifact-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";

import * as core from "@actions/core";
import * as exec from "@actions/exec";

import { Logger } from "./logging";
Expand Down Expand Up @@ -64,31 +63,6 @@ function scanFileForTokens(
): TokenFinding[] {
const findings: TokenFinding[] = [];
try {
// Skip binary files that are unlikely to contain tokens
const ext = path.extname(filePath).toLowerCase();
const binaryExtensions = [
".zip",
".tar",
".gz",
".bz2",
".xz",
".db",
".sqlite",
".bin",
".exe",
".dll",
".so",
".dylib",
".jpg",
".jpeg",
".png",
".gif",
".pdf",
];
if (binaryExtensions.includes(ext)) {
return [];
}

const content = fs.readFileSync(filePath, "utf8");

for (const { name, pattern } of GITHUB_TOKEN_PATTERNS) {
Expand Down Expand Up @@ -130,13 +104,9 @@ async function scanZipFile(
): Promise<ScanResult> {
const MAX_DEPTH = 10; // Prevent infinite recursion
if (depth > MAX_DEPTH) {
logger.warning(
throw new Error(
`Maximum zip extraction depth (${MAX_DEPTH}) reached for ${zipPath}`,
);
return {
scannedFiles: 0,
findings: [],
};
}

const result: ScanResult = {
Expand Down Expand Up @@ -237,38 +207,32 @@ async function scanDirectory(
findings: [],
};

try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const entries = fs.readdirSync(dirPath, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.join(baseRelativePath, entry.name);
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.join(baseRelativePath, entry.name);

if (entry.isDirectory()) {
const subResult = await scanDirectory(
fullPath,
relativePath,
logger,
depth,
);
result.scannedFiles += subResult.scannedFiles;
result.findings.push(...subResult.findings);
} else if (entry.isFile()) {
const fileResult = await scanFile(
fullPath,
relativePath,
path.dirname(fullPath),
logger,
depth,
);
result.scannedFiles += fileResult.scannedFiles;
result.findings.push(...fileResult.findings);
}
if (entry.isDirectory()) {
const subResult = await scanDirectory(
fullPath,
relativePath,
logger,
depth,
);
result.scannedFiles += subResult.scannedFiles;
result.findings.push(...subResult.findings);
} else if (entry.isFile()) {
const fileResult = await scanFile(
fullPath,
relativePath,
path.dirname(fullPath),
logger,
depth,
);
result.scannedFiles += fileResult.scannedFiles;
result.findings.push(...fileResult.findings);
}
} catch (e) {
logger.warning(
`Error scanning directory ${dirPath}: ${getErrorMessage(e)}`,
);
}

return result;
Expand All @@ -285,8 +249,10 @@ async function scanDirectory(
export async function scanArtifactsForTokens(
filesToScan: string[],
logger: Logger,
): Promise<ScanResult> {
logger.info("Starting security scan for GitHub tokens in debug artifacts...");
): Promise<void> {
logger.info(
"Starting best-effort check for potential GitHub tokens in debug artifacts (for testing purposes only)...",
);

const result: ScanResult = {
scannedFiles: 0,
Expand All @@ -298,26 +264,22 @@ export async function scanArtifactsForTokens(

try {
for (const filePath of filesToScan) {
try {
const stats = fs.statSync(filePath);
const fileName = path.basename(filePath);

if (stats.isDirectory()) {
const dirResult = await scanDirectory(filePath, fileName, logger);
result.scannedFiles += dirResult.scannedFiles;
result.findings.push(...dirResult.findings);
} else if (stats.isFile()) {
const fileResult = await scanFile(
filePath,
fileName,
tempScanDir,
logger,
);
result.scannedFiles += fileResult.scannedFiles;
result.findings.push(...fileResult.findings);
}
} catch (e) {
logger.warning(`Error scanning ${filePath}: ${getErrorMessage(e)}`);
const stats = fs.statSync(filePath);
const fileName = path.basename(filePath);

if (stats.isDirectory()) {
const dirResult = await scanDirectory(filePath, fileName, logger);
result.scannedFiles += dirResult.scannedFiles;
result.findings.push(...dirResult.findings);
} else if (stats.isFile()) {
const fileResult = await scanFile(
filePath,
fileName,
tempScanDir,
logger,
);
result.scannedFiles += fileResult.scannedFiles;
result.findings.push(...fileResult.findings);
}
}

Expand All @@ -341,12 +303,12 @@ export async function scanArtifactsForTokens(
? `${baseSummary} (${tokenTypesSummary})`
: baseSummary;

logger.info(`Security scan complete: ${summaryWithTypes}`);
logger.info(`Artifact check complete: ${summaryWithTypes}`);

if (result.findings.length > 0) {
const fileList = Array.from(filesWithTokens).join(", ");
core.warning(
`Found ${result.findings.length} potential GitHub token(s) (${tokenTypesSummary}) in debug artifacts at: ${fileList}. This may indicate a security issue. Please review the artifacts before sharing.`,
throw new Error(
`Found ${result.findings.length} potential GitHub token(s) (${tokenTypesSummary}) in debug artifacts at: ${fileList}. This is a best-effort check for testing purposes only.`,
);
}
} finally {
Expand All @@ -359,6 +321,4 @@ export async function scanArtifactsForTokens(
);
}
}

return result;
}