Skip to content

Commit 19bd86d

Browse files
ci: keep local plugin release scripts trusted (#2199)
* ci: keep local plugin release scripts trusted * ci: harden local plugin standalone publishing * ci: finalize dual local plugin release paths * ci: pair local plugin and MemOS releases * ci: harden local plugin npm verification * fix: preserve local plugin release draft diagnostics * ci: retry open code review * ci: retry open code review after scheduler fix --------- Co-authored-by: MLittleprince <MLittleprince@users.noreply.github.com>
1 parent 001c922 commit 19bd86d

23 files changed

Lines changed: 4087 additions & 651 deletions
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env node
2+
import { readFileSync, writeFileSync } from "node:fs";
3+
import { pathToFileURL } from "node:url";
4+
5+
export const INTENT_SCHEMA = "memos.local-plugin.release-intent.v2";
6+
export const INTENT_MARKER = "doc-agent-local-plugin-release-intent";
7+
const MEMOS_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
8+
const INTENT_RE = new RegExp(
9+
`<!--\\s*${INTENT_MARKER}\\s*\\n(\\{[^<]*\\})\\s*\\n-->`,
10+
"g",
11+
);
12+
13+
function fail(message) {
14+
throw new Error(String(message));
15+
}
16+
17+
function stableVersion(raw) {
18+
const value = String(raw || "").trim().replace(/^v/, "");
19+
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value)) {
20+
fail(`local plugin release intent requires a stable SemVer; received ${raw || "<empty>"}`);
21+
}
22+
return value;
23+
}
24+
25+
export function buildLocalPluginReleaseIntent({
26+
enabled,
27+
version = "",
28+
tag = "",
29+
sourceSha = "",
30+
evidenceDigest = "",
31+
memosReleaseTag = "",
32+
pluginReleaseUrl = "",
33+
} = {}) {
34+
const active = enabled === true || String(enabled) === "true";
35+
const normalizedMemOSTag = String(memosReleaseTag || "").trim();
36+
if (!MEMOS_TAG_RE.test(normalizedMemOSTag)) {
37+
fail(`local plugin release intent requires a stable MemOS release tag; received ${memosReleaseTag || "<empty>"}`);
38+
}
39+
if (!/^[0-9a-f]{64}$/.test(String(evidenceDigest || ""))) {
40+
fail("local plugin release intent requires a SHA-256 evidence_digest");
41+
}
42+
if (!active) {
43+
return {
44+
schema: INTENT_SCHEMA,
45+
enabled: false,
46+
memos_release_tag: normalizedMemOSTag,
47+
paired_release: false,
48+
version: "",
49+
tag: "",
50+
source_sha: "",
51+
evidence_digest: evidenceDigest,
52+
plugin_release_url: "",
53+
docs_trigger: "none",
54+
};
55+
}
56+
57+
const normalizedVersion = stableVersion(version);
58+
const expectedTag = `memos-local-plugin-v${normalizedVersion}`;
59+
if (String(tag || "").trim() !== expectedTag) {
60+
fail(`local plugin release intent tag must equal ${expectedTag}`);
61+
}
62+
if (!/^[0-9a-f]{40}$/.test(String(sourceSha || "").trim())) {
63+
fail("enabled local plugin release intent requires the 40-character published tag commit SHA");
64+
}
65+
const expectedUrl = `https://github.com/MemTensor/MemOS/releases/tag/${expectedTag}`;
66+
if (String(pluginReleaseUrl || "").trim() !== expectedUrl) {
67+
fail(`enabled local plugin release intent plugin_release_url must equal ${expectedUrl}`);
68+
}
69+
return {
70+
schema: INTENT_SCHEMA,
71+
enabled: true,
72+
memos_release_tag: normalizedMemOSTag,
73+
paired_release: true,
74+
version: `v${normalizedVersion}`,
75+
tag: expectedTag,
76+
source_sha: String(sourceSha).trim(),
77+
evidence_digest: evidenceDigest,
78+
plugin_release_url: expectedUrl,
79+
docs_trigger: "local_plugin_release_published",
80+
};
81+
}
82+
83+
export function parseLocalPluginReleaseIntent(body) {
84+
const matches = [...String(body || "").matchAll(INTENT_RE)];
85+
if (matches.length !== 1) {
86+
fail(`MemOS Release must contain exactly one local-plugin intent marker; found ${matches.length}`);
87+
}
88+
let payload;
89+
try {
90+
payload = JSON.parse(matches[0][1]);
91+
} catch {
92+
fail("MemOS local-plugin release intent is not valid JSON");
93+
}
94+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
95+
fail("MemOS local-plugin release intent must be an object");
96+
}
97+
if (payload.schema !== INTENT_SCHEMA) {
98+
fail(`unsupported MemOS local-plugin release intent schema ${payload.schema || "<empty>"}`);
99+
}
100+
const rebuilt = buildLocalPluginReleaseIntent({
101+
enabled: payload.enabled,
102+
version: payload.version,
103+
tag: payload.tag,
104+
sourceSha: payload.source_sha,
105+
evidenceDigest: payload.evidence_digest,
106+
memosReleaseTag: payload.memos_release_tag,
107+
pluginReleaseUrl: payload.plugin_release_url,
108+
});
109+
if (JSON.stringify(payload) !== JSON.stringify(rebuilt)) {
110+
fail("MemOS local-plugin release intent contains unexpected or inconsistent fields");
111+
}
112+
return payload;
113+
}
114+
115+
export function appendIntentToReleaseNotes(notes, intent) {
116+
const source = String(notes || "").trimEnd();
117+
if (!source) fail("MemOS release notes are empty");
118+
if (source.includes(`<!-- ${INTENT_MARKER}`)) {
119+
fail("MemOS release notes already contain a local plugin release intent marker");
120+
}
121+
return `${source}\n\n<!-- ${INTENT_MARKER}\n${JSON.stringify(intent)}\n-->\n`;
122+
}
123+
124+
export function main() {
125+
const notesFile = String(process.env.RELEASE_NOTES_FILE || "").trim();
126+
const outputFile = String(process.env.OUTPUT_RELEASE_NOTES_FILE || notesFile).trim();
127+
if (!notesFile || !outputFile) fail("RELEASE_NOTES_FILE and OUTPUT_RELEASE_NOTES_FILE are required");
128+
const intent = buildLocalPluginReleaseIntent({
129+
enabled: process.env.LOCAL_PLUGIN_RELEASE_ENABLED,
130+
version: process.env.LOCAL_PLUGIN_VERSION,
131+
tag: process.env.LOCAL_PLUGIN_TAG,
132+
sourceSha: process.env.LOCAL_PLUGIN_TAG_SHA,
133+
evidenceDigest: process.env.LOCAL_PLUGIN_EVIDENCE_DIGEST,
134+
memosReleaseTag: process.env.MEMOS_RELEASE_TAG,
135+
pluginReleaseUrl: process.env.LOCAL_PLUGIN_RELEASE_URL,
136+
});
137+
writeFileSync(outputFile, appendIntentToReleaseNotes(readFileSync(notesFile, "utf8"), intent), "utf8");
138+
console.log(`Appended ${INTENT_SCHEMA} marker (enabled=${intent.enabled}).`);
139+
}
140+
141+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
142+
try {
143+
main();
144+
} catch (error) {
145+
console.error(`::error::${error.message}`);
146+
process.exitCode = 1;
147+
}
148+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import {
5+
INTENT_SCHEMA,
6+
appendIntentToReleaseNotes,
7+
buildLocalPluginReleaseIntent,
8+
parseLocalPluginReleaseIntent,
9+
} from "./append-local-plugin-release-intent.mjs";
10+
11+
const digest = "a".repeat(64);
12+
13+
test("disabled intent contains no guessed version or source SHA", () => {
14+
assert.deepEqual(
15+
buildLocalPluginReleaseIntent({
16+
enabled: false,
17+
evidenceDigest: digest,
18+
memosReleaseTag: "v2.0.28",
19+
}),
20+
{
21+
schema: INTENT_SCHEMA,
22+
enabled: false,
23+
memos_release_tag: "v2.0.28",
24+
paired_release: false,
25+
version: "",
26+
tag: "",
27+
source_sha: "",
28+
evidence_digest: digest,
29+
plugin_release_url: "",
30+
docs_trigger: "none",
31+
},
32+
);
33+
});
34+
35+
test("enabled intent binds version, immutable tag, source SHA, and evidence", () => {
36+
const intent = buildLocalPluginReleaseIntent({
37+
enabled: true,
38+
version: "2.0.14",
39+
tag: "memos-local-plugin-v2.0.14",
40+
sourceSha: "b".repeat(40),
41+
evidenceDigest: digest,
42+
memosReleaseTag: "v2.0.28",
43+
pluginReleaseUrl: "https://github.com/MemTensor/MemOS/releases/tag/memos-local-plugin-v2.0.14",
44+
});
45+
assert.equal(intent.version, "v2.0.14");
46+
assert.equal(intent.source_sha, "b".repeat(40));
47+
const notes = appendIntentToReleaseNotes("## What's Changed\n", intent);
48+
assert.match(notes, /doc-agent-local-plugin-release-intent/);
49+
assert.deepEqual(parseLocalPluginReleaseIntent(notes), intent);
50+
});
51+
52+
test("enabled intent fails closed for mismatched tags and prereleases", () => {
53+
assert.throws(
54+
() => buildLocalPluginReleaseIntent({
55+
enabled: true,
56+
version: "2.0.14",
57+
tag: "memos-local-plugin-v2.0.15",
58+
sourceSha: "b".repeat(40),
59+
evidenceDigest: digest,
60+
memosReleaseTag: "v2.0.28",
61+
pluginReleaseUrl: "https://github.com/MemTensor/MemOS/releases/tag/memos-local-plugin-v2.0.15",
62+
}),
63+
/must equal/,
64+
);
65+
assert.throws(
66+
() => buildLocalPluginReleaseIntent({
67+
enabled: true,
68+
version: "2.0.14-beta.1",
69+
tag: "memos-local-plugin-v2.0.14-beta.1",
70+
sourceSha: "b".repeat(40),
71+
evidenceDigest: digest,
72+
memosReleaseTag: "v2.0.28",
73+
pluginReleaseUrl: "https://github.com/MemTensor/MemOS/releases/tag/memos-local-plugin-v2.0.14-beta.1",
74+
}),
75+
/stable SemVer/,
76+
);
77+
});
78+
79+
test("release notes refuse duplicate intent markers", () => {
80+
assert.throws(
81+
() => appendIntentToReleaseNotes("## Notes\n<!-- doc-agent-local-plugin-release-intent\n{}\n-->", {}),
82+
/already contain/,
83+
);
84+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { execFileSync } from "node:child_process";
2+
import {
3+
mkdtempSync,
4+
readFileSync,
5+
readdirSync,
6+
rmSync,
7+
statSync,
8+
writeFileSync,
9+
} from "node:fs";
10+
import { tmpdir } from "node:os";
11+
import { basename, join, relative, sep } from "node:path";
12+
import { pathToFileURL } from "node:url";
13+
14+
const FORBIDDEN_PATHS = [
15+
/(^|\/)\.env(?:\.|$)/i,
16+
/(^|\/)\.npmrc$/i,
17+
/(^|\/)\.git(?:\/|$)/i,
18+
/(^|\/)(?:id_rsa|id_ed25519)$/i,
19+
/\.(?:pem|p12|pfx|key)$/i,
20+
];
21+
const SECRET_PATTERNS = [
22+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
23+
/(?:^|[^A-Za-z0-9])npm_[A-Za-z0-9]{20,}/,
24+
/(?:^|[^A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}/,
25+
/github_pat_[A-Za-z0-9_]{20,}/,
26+
/\/\/(?:registry\.)?npmjs\.org\/:_authToken\s*=/i,
27+
/Authorization\s*[:=]\s*Bearer\s+[A-Za-z0-9._-]{16,}/i,
28+
/(?:^|[^A-Za-z0-9])sk-[A-Za-z0-9]{20,}/,
29+
/(?:^|[^A-Z0-9])AKIA[0-9A-Z]{16}(?:[^A-Z0-9]|$)/,
30+
];
31+
32+
function collectFiles(directory) {
33+
const files = [];
34+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
35+
const path = join(directory, entry.name);
36+
if (entry.isDirectory()) {
37+
files.push(...collectFiles(path));
38+
} else if (entry.isFile()) {
39+
files.push(path);
40+
}
41+
}
42+
return files;
43+
}
44+
45+
export function auditPackage(tarball) {
46+
const listing = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" })
47+
.split("\n")
48+
.filter(Boolean);
49+
const unsafeArchivePaths = listing.filter(
50+
(entry) =>
51+
entry.startsWith("/") ||
52+
entry.split("/").includes("..") ||
53+
!entry.startsWith("package/") ||
54+
FORBIDDEN_PATHS.some((pattern) => pattern.test(entry)),
55+
);
56+
if (unsafeArchivePaths.length > 0) {
57+
throw new Error(`package contains forbidden path: ${unsafeArchivePaths[0]}`);
58+
}
59+
const linkedEntries = execFileSync("tar", ["-tvzf", tarball], { encoding: "utf8" })
60+
.split("\n")
61+
.filter((line) => /^[lh]/.test(line));
62+
if (linkedEntries.length > 0) {
63+
throw new Error("package contains a symbolic or hard link; refusing unsafe extraction");
64+
}
65+
66+
const extractDirectory = mkdtempSync(join(tmpdir(), "memos-local-plugin-audit-"));
67+
try {
68+
execFileSync("tar", ["-xzf", tarball, "-C", extractDirectory]);
69+
const packageRoot = join(extractDirectory, "package");
70+
const files = collectFiles(packageRoot);
71+
const secretFiles = [];
72+
let scannedTextFileCount = 0;
73+
for (const file of files) {
74+
const size = statSync(file).size;
75+
if (size === 0 || size > 2 * 1024 * 1024) {
76+
continue;
77+
}
78+
const bytes = readFileSync(file);
79+
if (bytes.includes(0)) {
80+
continue;
81+
}
82+
scannedTextFileCount += 1;
83+
const text = bytes.toString("utf8");
84+
if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) {
85+
secretFiles.push(relative(packageRoot, file).split(sep).join("/"));
86+
}
87+
}
88+
if (secretFiles.length > 0) {
89+
throw new Error(`package contains a credential-like value in ${secretFiles[0]}`);
90+
}
91+
return {
92+
tarball: basename(tarball),
93+
archive_entry_count: listing.length,
94+
scanned_text_file_count: scannedTextFileCount,
95+
forbidden_path_count: 0,
96+
credential_finding_count: 0,
97+
status: "pass",
98+
};
99+
} finally {
100+
rmSync(extractDirectory, { recursive: true, force: true });
101+
}
102+
}
103+
104+
export function main() {
105+
const tarball = process.env.RELEASE_TARBALL || "";
106+
const reportFile = process.env.PACKAGE_AUDIT_REPORT || "";
107+
if (!tarball || !reportFile) {
108+
throw new Error("RELEASE_TARBALL and PACKAGE_AUDIT_REPORT are required");
109+
}
110+
const report = auditPackage(tarball);
111+
writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
112+
console.log(`Package audit passed for ${report.tarball}; scanned ${report.archive_entry_count} entries.`);
113+
}
114+
115+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
116+
try {
117+
main();
118+
} catch (error) {
119+
console.error(`::error::${error.message}`);
120+
process.exitCode = 1;
121+
}
122+
}

0 commit comments

Comments
 (0)