Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/scripts/audit-local-plugin-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, relative, sep } from "node:path";
import { pathToFileURL } from "node:url";

const FORBIDDEN_PATHS = [
/(^|\/)\.env(?:\.|$)/i,
/(^|\/)\.npmrc$/i,
/(^|\/)\.git(?:\/|$)/i,
/(^|\/)(?:id_rsa|id_ed25519)$/i,
/\.(?:pem|p12|pfx|key)$/i,
];
const SECRET_PATTERNS = [
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
/(?:^|[^A-Za-z0-9])npm_[A-Za-z0-9]{20,}/,
/(?:^|[^A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}/,
/github_pat_[A-Za-z0-9_]{20,}/,
/\/\/(?:registry\.)?npmjs\.org\/:_authToken\s*=/i,
/Authorization\s*[:=]\s*Bearer\s+[A-Za-z0-9._-]{16,}/i,
/(?:^|[^A-Za-z0-9])sk-[A-Za-z0-9]{20,}/,
/(?:^|[^A-Z0-9])AKIA[0-9A-Z]{16}(?:[^A-Z0-9]|$)/,
];

function collectFiles(directory) {
const files = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...collectFiles(path));
} else if (entry.isFile()) {
files.push(path);
}
}
return files;
}

export function auditPackage(tarball) {
const listing = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" })
.split("\n")
.filter(Boolean);
const unsafeArchivePaths = listing.filter(
(entry) =>
entry.startsWith("/") ||
entry.split("/").includes("..") ||
!entry.startsWith("package/") ||
FORBIDDEN_PATHS.some((pattern) => pattern.test(entry)),
);
if (unsafeArchivePaths.length > 0) {
throw new Error(`package contains forbidden path: ${unsafeArchivePaths[0]}`);
}
const linkedEntries = execFileSync("tar", ["-tvzf", tarball], { encoding: "utf8" })
.split("\n")
.filter((line) => /^[lh]/.test(line));
if (linkedEntries.length > 0) {
throw new Error("package contains a symbolic or hard link; refusing unsafe extraction");
}

const extractDirectory = mkdtempSync(join(tmpdir(), "memos-local-plugin-audit-"));
try {
execFileSync("tar", ["-xzf", tarball, "-C", extractDirectory]);
const packageRoot = join(extractDirectory, "package");
const files = collectFiles(packageRoot);
const secretFiles = [];
let scannedTextFileCount = 0;
for (const file of files) {
const size = statSync(file).size;
if (size === 0 || size > 2 * 1024 * 1024) {
continue;
}
const bytes = readFileSync(file);
if (bytes.includes(0)) {
continue;
}
scannedTextFileCount += 1;
const text = bytes.toString("utf8");
if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) {
secretFiles.push(relative(packageRoot, file).split(sep).join("/"));
}
}
if (secretFiles.length > 0) {
throw new Error(`package contains a credential-like value in ${secretFiles[0]}`);
}
return {
tarball: basename(tarball),
archive_entry_count: listing.length,
scanned_text_file_count: scannedTextFileCount,
forbidden_path_count: 0,
credential_finding_count: 0,
status: "pass",
};
} finally {
rmSync(extractDirectory, { recursive: true, force: true });
}
}

export function main() {
const tarball = process.env.RELEASE_TARBALL || "";
const reportFile = process.env.PACKAGE_AUDIT_REPORT || "";
if (!tarball || !reportFile) {
throw new Error("RELEASE_TARBALL and PACKAGE_AUDIT_REPORT are required");
}
const report = auditPackage(tarball);
writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`Package audit passed for ${report.tarball}; scanned ${report.archive_entry_count} entries.`);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
} catch (error) {
console.error(`::error::${error.message}`);
process.exitCode = 1;
}
}
64 changes: 64 additions & 0 deletions .github/scripts/audit-local-plugin-package.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

import { auditPackage } from "./audit-local-plugin-package.mjs";

function withPackage(files, callback) {
const directory = mkdtempSync(join(tmpdir(), "memos-package-audit-test-"));
const packageDirectory = join(directory, "package");
mkdirSync(packageDirectory);
for (const [path, contents] of Object.entries(files)) {
const target = join(packageDirectory, path);
mkdirSync(join(target, ".."), { recursive: true });
writeFileSync(target, contents, "utf8");
}
const tarball = join(directory, "package.tgz");
execFileSync("tar", ["-czf", tarball, "-C", directory, "package"]);
try {
callback(tarball);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}

test("accepts expected package content including public telemetry configuration", () => {
withPackage(
{
"package.json": '{"name":"@memtensor/memos-local-plugin","version":"2.0.13-beta.1"}\n',
"telemetry.credentials.json": '{"endpoint":"https://example.invalid/rum","pid":"public-id"}\n',
"dist/index.js": "export const ok = true;\n",
},
(tarball) => {
const report = auditPackage(tarball);
assert.equal(report.status, "pass");
assert.equal(report.credential_finding_count, 0);
},
);
});

test("rejects credential files and credential-like values", () => {
withPackage({ ".npmrc": "//registry.npmjs.org/:_authToken=npm_example\n" }, (tarball) => {
assert.throws(() => auditPackage(tarball), /forbidden path/);
});
withPackage({ "dist/config.js": `const token = "github_pat_${"a".repeat(24)}";\n` }, (tarball) => {
assert.throws(() => auditPackage(tarball), /credential-like value/);
});
});

test("rejects package symlinks before extraction", () => {
const directory = mkdtempSync(join(tmpdir(), "memos-package-audit-link-test-"));
const packageDirectory = join(directory, "package");
mkdirSync(packageDirectory);
symlinkSync("/tmp", join(packageDirectory, "unsafe-link"));
const tarball = join(directory, "package.tgz");
execFileSync("tar", ["-czf", tarball, "-C", directory, "package"]);
try {
assert.throws(() => auditPackage(tarball), /symbolic or hard link/);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
Loading
Loading