import { ExtPackageJson, License } from "@kksh/api/models" import * as v from "valibot" import { authenticatedUserIsMemberOfGitHubOrg, parseGitHubRepoFromUri, userIsPublicMemberOfGitHubOrg } from "../github" import type { ExtensionPublishValidationData } from "../models" import { getInfoFromRekorLog } from "../sigstore" import { getRawFileFromGitHub, getTarballSize } from "../utils" import { NpmPkgMetadata, NpmPkgVersionMetadata, NpmSearchResultObject, NpmSearchResults, Provenance } from "./models" export * from "./models" /** * Get the full metadata of an npm package * @param pkgName * @returns */ export function getFullNpmPackageInfo(pkgName: string): Promise { return fetch(`https://registry.npmjs.org/${pkgName}`).then((res) => (res.ok ? res.json() : null)) } /** * Fetch the package.json data of an npm package * @param pkgName * @param version * @returns */ export function getNpmPackageInfoByVersion( pkgName: string, version: string ): Promise { return fetch(`https://registry.npmjs.org/${pkgName}/${version}`).then((res) => res.ok ? res.json() : null ) } /** * Get the provenance of an npm package * If a package has no provenance, return null * @param pkgName * @param version * @returns */ export function getNpmPkgProvenance(pkgName: string, version: string): Promise { return fetch(`https://www.npmjs.com/package/${pkgName}/v/${version}/provenance`) .then((res) => res.json()) .catch((err) => null) } /** * List all packages under a scope * @example * To get package names under a scope, you can do: * ```ts * (await listPackagesOfMaintainer("huakunshen")).map((pkg) => pkg.package.name) * ``` * @param username npm organization or username * @returns */ export function listPackagesOfMaintainer(username: string): Promise { return fetch(`https://registry.npmjs.org/-/v1/search?text=maintainer:${username}&size=250`, { headers: { "sec-fetch-dest": "document" } }) .then((res) => res.json()) .then((res) => v.parse(NpmSearchResults, res).objects) } export function listPackagesOfScope(scope: string): Promise { return fetch(`https://registry.npmjs.org/-/v1/search?text=${scope}&size=250`, { headers: { "sec-fetch-dest": "document" } }) .then((res) => res.json()) .then((res) => v.parse(NpmSearchResults, res).objects) } export function getNpmPackageTarballUrl( pkgName: string, version: string ): Promise { return getNpmPackageInfoByVersion(pkgName, version).then((res) => res?.dist?.tarball) } export function npmPackageExists(pkgName: string, version: string): Promise { return getNpmPackageInfoByVersion(pkgName, version).then((res) => res !== null) } /** * @param url Sample URL: https://search.sigstore.dev/?logIndex=153252145 * @returns */ function parseLogIdFromSigstoreSearchUrl(url: string): string { const urlObj = new URL(url) const logIndex = urlObj.searchParams.get("logIndex") if (!logIndex) { throw new Error("Could not parse log index from sigstore search url") } return logIndex } export async function validateNpmPackageAsKunkunExtension(payload: { pkgName: string version: string githubUsername: string tarballSizeLimit?: number githubToken?: string provenance?: Provenance // provenance API has cors policy, when we run this validation on client side, a provenance should be passed in }): Promise<{ error?: string data?: ExtensionPublishValidationData }> { /* -------------------------------------------------------------------------- */ /* check if npm package exist */ /* -------------------------------------------------------------------------- */ const pkgExists = await npmPackageExists(payload.pkgName, payload.version) if (!pkgExists) { return { error: "Package does not exist" } } if (!pkgExists) { return { error: "NPM package does not exist" } } /* -------------------------------------------------------------------------- */ /* check if npm package has provenance */ /* -------------------------------------------------------------------------- */ const provenance = payload.provenance ?? (await getNpmPkgProvenance(payload.pkgName, payload.version)) if (!provenance) { return { error: "Package doesn't have provenance, not signed by github action" } } if (provenance.sourceCommitUnreachable) { return { error: "Package's source commit is unreachable" } } if (provenance.sourceCommitNotFound) { return { error: "Package's source commit is not found" } } /* -------------------------------------------------------------------------- */ /* get rekor sigstore */ /* -------------------------------------------------------------------------- */ if (!provenance?.summary.transparencyLogUri) { return { error: "Package's rekor log is not found" } } const logIndex = parseLogIdFromSigstoreSearchUrl(provenance.summary.transparencyLogUri) const rekorGit = await getInfoFromRekorLog(logIndex) if (rekorGit.commit !== provenance.summary.sourceRepositoryDigest) { return { error: "Package's rekor log commit is not the same as the source commit" } } /* -------------------------------------------------------------------------- */ /* check if npm pkg is linked to github repo */ /* -------------------------------------------------------------------------- */ const repoUri = provenance.summary.sourceRepositoryUri const githubRepo = parseGitHubRepoFromUri(repoUri) /* -------------------------------------------------------------------------- */ /* Verify Repo Ownership */ /* -------------------------------------------------------------------------- */ if (githubRepo.owner !== payload.githubUsername) { const isPublicMemeber = await userIsPublicMemberOfGitHubOrg( githubRepo.owner, payload.githubUsername ) let isOrgMember = false if (payload.githubToken) { isOrgMember = await authenticatedUserIsMemberOfGitHubOrg( githubRepo.owner, payload.githubToken ) } if (!isPublicMemeber && !isOrgMember) { return { error: `You (${payload.githubUsername}) are not authorized to publish this package. Only ${githubRepo.owner} or its organization members can publish it.` } } } /* -------------------------------------------------------------------------- */ /* validate package.json format against latest schema */ /* -------------------------------------------------------------------------- */ const packageJson = await getNpmPackageInfoByVersion(payload.pkgName, payload.version) if (!packageJson) { return { error: "Could not find package.json in NPM package" } } if (!packageJson.license) { return { error: "Package license field is not found" } } const licenseParsed = v.safeParse(License, packageJson.license) if (!licenseParsed.success) { return { error: `Package license field ${packageJson.license} is not valid` } } const parseResult = v.safeParse(ExtPackageJson, packageJson) if (!parseResult.success) { console.error(v.flatten(parseResult.issues)) return { error: `package.json format not valid` } } /* -------------------------------------------------------------------------- */ /* get more package info */ /* -------------------------------------------------------------------------- */ const tarballUrl = packageJson.dist?.tarball if (!tarballUrl) { return { error: "Could not get tarball URL for NPM package" } } const shasum = packageJson.dist?.shasum if (!shasum) { return { error: "Could not get shasum for NPM package" } } const apiVersion = parseResult.output.dependencies?.["@kksh/api"] if (!apiVersion) { return { error: `Extension ${parseResult.output.kunkun.identifier} doesn't not have @kksh/api as a dependency` } } const tarballSize = await getTarballSize(tarballUrl, "GET") // NPM HEAD request doesn't support content-length const readmeContent = await getRawFileFromGitHub( githubRepo.owner, githubRepo.repo, provenance.summary.sourceRepositoryDigest, parseResult.output.readme ?? "README.md" ) return { data: { pkgJson: parseResult.output, license: licenseParsed.output, readmeContent, tarballUrl, shasum, apiVersion, tarballSize, unpackedSize: packageJson.dist?.unpackedSize, rekorLogIndex: logIndex, github: { githubActionInvocationId: rekorGit.githubActionInvocationId, commit: provenance.summary.sourceRepositoryDigest, repo: githubRepo.repo, owner: githubRepo.owner, workflowPath: rekorGit.workflowPath } } } }