fix: cover every tracked release input
This commit is contained in:
@@ -13,6 +13,8 @@ type VerifyBuildManifestOutputsDependencies = Readonly<{
|
||||
assertDirectory?: (target: string) => Promise<void>;
|
||||
}>;
|
||||
|
||||
export const CANONICAL_VITE_MANIFEST_PATH = "dist/.vite/manifest.json";
|
||||
|
||||
function isSafeRelativePath(value: string): boolean {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
@@ -28,7 +30,12 @@ function isSafeRelativePath(value: string): boolean {
|
||||
|
||||
function isWithinRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function belongsToApprovedRoot(value: string, approvedRoot: string): boolean {
|
||||
@@ -127,12 +134,16 @@ export async function verifyBuildManifestOutputs(
|
||||
} else {
|
||||
await confinedPath("directory", manifest.outputs.directory, "directory");
|
||||
}
|
||||
await confinedPath(
|
||||
"viteManifest",
|
||||
manifest.outputs.viteManifest,
|
||||
"file",
|
||||
"dist",
|
||||
);
|
||||
if (manifest.outputs.viteManifest !== CANONICAL_VITE_MANIFEST_PATH) {
|
||||
mismatches.push("buildManifest:viteManifest:path");
|
||||
} else {
|
||||
await confinedPath(
|
||||
"viteManifest",
|
||||
manifest.outputs.viteManifest,
|
||||
"file",
|
||||
"dist",
|
||||
);
|
||||
}
|
||||
await confinedPath(
|
||||
"runtimeConfigSchema",
|
||||
manifest.outputs.runtimeConfigSchema,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
|
||||
export async function digestReleaseInputFiles(
|
||||
files: readonly string[],
|
||||
readBytes: (file: string) => Promise<Buffer> = readFile,
|
||||
): Promise<string> {
|
||||
const rows = await Promise.all(
|
||||
[...files].sort().map(async (file) => ({
|
||||
path: file,
|
||||
sha256: createHash("sha256")
|
||||
.update(await readBytes(file))
|
||||
.digest("hex"),
|
||||
})),
|
||||
);
|
||||
return supplyChainDigest(rows);
|
||||
}
|
||||
@@ -131,11 +131,12 @@ function normalizeRepositoryPath(value: string, label: string): string {
|
||||
|
||||
function isWithinRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function belongsToRoots(file: string, roots: readonly string[]): boolean {
|
||||
return roots.some((root) => file === root || file.startsWith(`${root}/`));
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function parseGitFileList(result: GitFileListResult): string[] {
|
||||
@@ -261,9 +262,7 @@ export async function buildRepositoryFileInventory(
|
||||
|
||||
const trackedFiles = parseGitFileList(
|
||||
(options.runGit ?? defaultGitFileList)(repositoryRoot),
|
||||
)
|
||||
.filter((file) => belongsToRoots(file, trackedRoots))
|
||||
.sort();
|
||||
).sort();
|
||||
for (const root of trackedRoots) {
|
||||
if (!trackedFiles.some((file) => file === root || file.startsWith(`${root}/`))) {
|
||||
throw new Error(`required tracked file inventory is empty: ${root}`);
|
||||
@@ -309,9 +308,16 @@ export async function buildRepositoryFileInventory(
|
||||
}
|
||||
const uniqueTracked = [...trackedFiles].sort();
|
||||
const uniqueGenerated = [...generatedFiles].sort();
|
||||
const trackedSet = new Set(uniqueTracked);
|
||||
const collisions = uniqueGenerated.filter((file) => trackedSet.has(file));
|
||||
if (collisions.length > 0) {
|
||||
throw new TypeError(
|
||||
`tracked and generated inventory paths collide: ${collisions.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
trackedFiles: Object.freeze(uniqueTracked),
|
||||
generatedFiles: Object.freeze(uniqueGenerated),
|
||||
files: Object.freeze([...new Set([...uniqueTracked, ...uniqueGenerated])].sort()),
|
||||
files: Object.freeze([...uniqueTracked, ...uniqueGenerated].sort()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export type SecretFinding = Readonly<{
|
||||
ruleId: string;
|
||||
file: string;
|
||||
line: number;
|
||||
fingerprint: string;
|
||||
}>;
|
||||
|
||||
export type SecretAllowlistEntry = Readonly<{
|
||||
path: string;
|
||||
ruleId: string;
|
||||
expiresAt: string;
|
||||
}>;
|
||||
|
||||
const secretPatterns: readonly Readonly<{
|
||||
id: string;
|
||||
expression: RegExp;
|
||||
}>[] = [
|
||||
{
|
||||
id: "private-key",
|
||||
expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g,
|
||||
},
|
||||
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
|
||||
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
|
||||
{
|
||||
id: "assigned-secret",
|
||||
expression:
|
||||
/(?<![\w])(["']?)(?:client_secret|password|private_key)\1(?![\w])\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
|
||||
},
|
||||
];
|
||||
|
||||
export function secretScanRules(): readonly Readonly<{
|
||||
id: string;
|
||||
expression: RegExp;
|
||||
}>[] {
|
||||
return secretPatterns;
|
||||
}
|
||||
|
||||
export function findSecretMatches(
|
||||
file: string,
|
||||
content: string,
|
||||
options: Readonly<{
|
||||
allowlist?: readonly SecretAllowlistEntry[];
|
||||
now?: number;
|
||||
}> = {},
|
||||
): SecretFinding[] {
|
||||
const allowlist = options.allowlist ?? [];
|
||||
const now = options.now ?? Date.now();
|
||||
const findings: SecretFinding[] = [];
|
||||
for (const pattern of secretPatterns) {
|
||||
pattern.expression.lastIndex = 0;
|
||||
for (const match of content.matchAll(pattern.expression)) {
|
||||
const isAllowed = allowlist.some(
|
||||
(entry) =>
|
||||
entry.path === file &&
|
||||
entry.ruleId === pattern.id &&
|
||||
Date.parse(entry.expiresAt) > now,
|
||||
);
|
||||
if (isAllowed) continue;
|
||||
const matchIndex = match.index ?? 0;
|
||||
findings.push({
|
||||
ruleId: pattern.id,
|
||||
file,
|
||||
line: content.slice(0, matchIndex).split(/\r?\n/u).length,
|
||||
fingerprint: createHash("sha256")
|
||||
.update(`${pattern.id}:${file}:${String(matchIndex)}`)
|
||||
.digest("hex"),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
Reference in New Issue
Block a user