mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
268 lines
14 KiB
JavaScript
268 lines
14 KiB
JavaScript
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
const { describe, it } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const childProcess = require("node:child_process");
|
|
const crypto = require("node:crypto");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const zlib = require("node:zlib");
|
|
|
|
const { MAX_FACTS_BYTES, extractEntryFromBuffer, verifyArtifactDigest, verifyZipEntries, writeVerifiedFacts } = require("./semantic-review-verify-artifact.js");
|
|
|
|
describe("verifyZipEntries", () => {
|
|
it("rejects path traversal and symlink entries", () => {
|
|
const badEntries = [
|
|
{ fileName: "../facts.json", externalFileAttributes: 0, compressedSize: 10, uncompressedSize: 10 },
|
|
{ fileName: "facts.json", externalFileAttributes: 0o120000 << 16, compressedSize: 10, uncompressedSize: 10 },
|
|
{ fileName: "facts.json", externalFileAttributes: 0o040000 << 16, compressedSize: 10, uncompressedSize: 10 },
|
|
];
|
|
for (const entry of badEntries) {
|
|
assert.throws(() => verifyZipEntries([entry]));
|
|
}
|
|
});
|
|
|
|
it("rejects multi-file and oversized artifacts", () => {
|
|
const entry = { fileName: "facts.json", externalFileAttributes: 0o100644 << 16, compressedSize: 100, uncompressedSize: 100 };
|
|
assert.throws(() => verifyZipEntries([entry, entry]));
|
|
assert.throws(() => verifyZipEntries([{ ...entry, uncompressedSize: MAX_FACTS_BYTES + 1 }]));
|
|
});
|
|
|
|
it("rejects suspicious compression ratios", () => {
|
|
const entry = { fileName: "facts.json", externalFileAttributes: 0o100644 << 16, compressedSize: 1, uncompressedSize: 1000 };
|
|
assert.throws(() => verifyZipEntries([entry]), /compression ratio/);
|
|
});
|
|
|
|
it("accepts exactly one regular facts file", () => {
|
|
const entry = { fileName: "facts.json", externalFileAttributes: 0o100644 << 16, compressedSize: 100, uncompressedSize: 100 };
|
|
assert.equal(verifyZipEntries([entry]), entry);
|
|
});
|
|
|
|
it("validates artifact sha256 digest when provided", () => {
|
|
const buf = Buffer.from("artifact");
|
|
const digest = crypto.createHash("sha256").update(buf).digest("hex");
|
|
assert.throws(() => verifyArtifactDigest(buf, ""), /artifact digest is required/);
|
|
assert.doesNotThrow(() => verifyArtifactDigest(buf, `sha256:${digest}`));
|
|
assert.throws(() => verifyArtifactDigest(buf, `sha256:${"0".repeat(64)}`), /digest mismatch/);
|
|
assert.throws(() => verifyArtifactDigest(buf, "md5:bad"), /unsupported artifact digest/);
|
|
});
|
|
|
|
it("caps deflated facts extraction before zip size mismatch checks", () => {
|
|
const header = Buffer.alloc(30);
|
|
header.writeUInt32LE(0x04034b50, 0);
|
|
header.writeUInt16LE(8, 8);
|
|
const compressed = zlib.deflateRawSync(Buffer.alloc(MAX_FACTS_BYTES + 1, "x"));
|
|
const entry = {
|
|
localHeaderOffset: 0,
|
|
compressedSize: compressed.length,
|
|
uncompressedSize: MAX_FACTS_BYTES,
|
|
};
|
|
|
|
assert.throws(() => extractEntryFromBuffer(Buffer.concat([header, compressed]), entry));
|
|
});
|
|
|
|
it("extracts facts from a real zip buffer", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
|
|
const zipPath = path.join(dir, "facts.zip");
|
|
const outPath = path.join(dir, "facts.json");
|
|
const restrictedScope = "pri" + "vate";
|
|
const facts = Buffer.from(JSON.stringify({
|
|
schema_version: 1,
|
|
public_content: [
|
|
{
|
|
rule: "public_content_semantic_candidate",
|
|
action: "WARNING",
|
|
file: "pull_request_metadata",
|
|
line: 1,
|
|
source: "metadata",
|
|
excerpt: "public release notes mention an internal rollout plan",
|
|
message: "public contribution may contain sensitive implementation detail",
|
|
suggestion: "move internal detail to " + restrictedScope + " discussion",
|
|
},
|
|
{
|
|
rule: "public_content_change_id_trailer",
|
|
action: "REJECT",
|
|
file: "commit:1234abc",
|
|
line: 3,
|
|
source: "commit",
|
|
},
|
|
{
|
|
rule: "public_content_automation_branch",
|
|
action: "WARNING",
|
|
file: "branch",
|
|
line: 1,
|
|
source: "branch",
|
|
},
|
|
{
|
|
rule: "public_content_" + "pri" + "vate_ipv4",
|
|
action: "WARNING",
|
|
file: "docs/public-network.md",
|
|
line: 7,
|
|
source: "file",
|
|
},
|
|
],
|
|
}) + "\n");
|
|
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
|
|
fs.writeFileSync(zipPath, zip);
|
|
|
|
writeVerifiedFacts(zipPath, outPath, digestFor(zip));
|
|
|
|
assert.equal(fs.readFileSync(outPath, "utf8"), facts.toString("utf8"));
|
|
});
|
|
|
|
it("rejects malformed zip boundaries with a controlled error", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
|
|
const zipPath = path.join(dir, "facts.zip");
|
|
const outPath = path.join(dir, "facts.json");
|
|
const zip = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
fs.writeFileSync(zipPath, zip);
|
|
|
|
assert.throws(
|
|
() => writeVerifiedFacts(zipPath, outPath, digestFor(zip)),
|
|
/zip end of central directory|zip central directory|zip bounds/,
|
|
);
|
|
});
|
|
|
|
it("rejects invalid facts JSON shape", () => {
|
|
for (const [name, facts, want] of [
|
|
["not-json", Buffer.from("{"), /facts JSON is invalid/],
|
|
["array", Buffer.from("[]"), /facts JSON must be an object/],
|
|
["wrong-schema", Buffer.from('{"schema_version":2}'), /schema_version/],
|
|
["non-array-skills", Buffer.from('{"schema_version":1,"skills":{}}'), /skills must be an array/],
|
|
["bad-skill-path", Buffer.from('{"schema_version":1,"skills":[{"source_file":"../x","line":1,"raw":"x","references_invalid_command":true}]}'), /source_file/],
|
|
["bad-skill-line", Buffer.from('{"schema_version":1,"skills":[{"source_file":"skills/lark-doc/SKILL.md","line":"3","raw":"x","references_invalid_command":true}]}'), /line/],
|
|
["bad-command-item", Buffer.from('{"schema_version":1,"commands":["not-object"]}'), /commands\[0\]/],
|
|
["bad-command-flags", Buffer.from('{"schema_version":1,"commands":[{"path":"docs +fetch","source":"shortcut","flags":["ok",1]}]}'), /commands\[0\]\.flags\[1\]/],
|
|
["bad-skill-quality-path", Buffer.from('{"schema_version":1,"skill_quality":[{"source_file":"/tmp/SKILL.md","word_count":1,"critical_count":0,"description_length":10}]}'), /skill_quality\[0\]\.source_file/],
|
|
["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/],
|
|
["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/],
|
|
["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/],
|
|
["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/],
|
|
["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/],
|
|
["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/],
|
|
["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/],
|
|
["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/],
|
|
["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/],
|
|
["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/],
|
|
["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/],
|
|
["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/],
|
|
["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/],
|
|
["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/],
|
|
["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/],
|
|
["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/],
|
|
["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/],
|
|
["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/],
|
|
]) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `facts-shape-${name}-`));
|
|
const zipPath = path.join(dir, "facts.zip");
|
|
const outPath = path.join(dir, "facts.json");
|
|
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
|
|
fs.writeFileSync(zipPath, zip);
|
|
assert.throws(() => writeVerifiedFacts(zipPath, outPath, digestFor(zip)), want);
|
|
}
|
|
});
|
|
|
|
it("rejects invalid entries through real zip parsing", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
|
|
for (const [name, zip] of [
|
|
["duplicate", makeZip([
|
|
{ fileName: "facts.json", data: Buffer.from("{}"), mode: 0o100644 },
|
|
{ fileName: "facts.json", data: Buffer.from("{}"), mode: 0o100644 },
|
|
])],
|
|
["path-traversal", makeZip([{ fileName: "../facts.json", data: Buffer.from("{}"), mode: 0o100644 }])],
|
|
["symlink", makeZip([{ fileName: "facts.json", data: Buffer.from("target"), mode: 0o120000 }])],
|
|
]) {
|
|
const zipPath = path.join(dir, `${name}.zip`);
|
|
fs.writeFileSync(zipPath, zip);
|
|
assert.throws(() => writeVerifiedFacts(zipPath, path.join(dir, `${name}.json`), digestFor(zip)), /artifact|path|symlink|regular/);
|
|
}
|
|
});
|
|
|
|
it("writes an infrastructure decision when CLI verification fails", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
|
|
const zipPath = path.join(dir, "facts.zip");
|
|
const outPath = path.join(dir, "facts.json");
|
|
const decisionPath = path.join(dir, "decision.json");
|
|
const zip = makeZip([{ fileName: "../facts.json", data: Buffer.from("{}"), mode: 0o100644 }]);
|
|
fs.writeFileSync(zipPath, zip);
|
|
|
|
const result = childProcess.spawnSync(process.execPath, [path.join(__dirname, "semantic-review-verify-artifact.js"), zipPath, outPath, digestFor(zip)], {
|
|
env: {
|
|
...process.env,
|
|
SEMANTIC_REVIEW_BLOCK: "true",
|
|
SEMANTIC_REVIEW_DECISION_OUT: decisionPath,
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /invalid artifact path/);
|
|
const decision = JSON.parse(fs.readFileSync(decisionPath, "utf8"));
|
|
assert.equal(decision.block_mode, true);
|
|
assert.equal(decision.infrastructure_failure, true);
|
|
assert.match(decision.system_warnings[0].message, /invalid artifact path/);
|
|
});
|
|
});
|
|
|
|
function digestFor(buf) {
|
|
const digest = crypto.createHash("sha256").update(buf).digest("hex");
|
|
return `sha256:${digest}`;
|
|
}
|
|
|
|
function makeZip(entries) {
|
|
const locals = [];
|
|
const centrals = [];
|
|
let offset = 0;
|
|
for (const entry of entries) {
|
|
const name = Buffer.from(entry.fileName);
|
|
const data = Buffer.from(entry.data);
|
|
const local = Buffer.alloc(30);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(20, 4);
|
|
local.writeUInt16LE(0, 6);
|
|
local.writeUInt16LE(0, 8);
|
|
local.writeUInt32LE(0, 10);
|
|
local.writeUInt32LE(0, 14);
|
|
local.writeUInt32LE(data.length, 18);
|
|
local.writeUInt32LE(data.length, 22);
|
|
local.writeUInt16LE(name.length, 26);
|
|
local.writeUInt16LE(0, 28);
|
|
locals.push(local, name, data);
|
|
|
|
const central = Buffer.alloc(46);
|
|
central.writeUInt32LE(0x02014b50, 0);
|
|
central.writeUInt16LE(0x0314, 4);
|
|
central.writeUInt16LE(20, 6);
|
|
central.writeUInt16LE(0, 8);
|
|
central.writeUInt16LE(0, 10);
|
|
central.writeUInt32LE(0, 12);
|
|
central.writeUInt32LE(0, 16);
|
|
central.writeUInt32LE(data.length, 20);
|
|
central.writeUInt32LE(data.length, 24);
|
|
central.writeUInt16LE(name.length, 28);
|
|
central.writeUInt16LE(0, 30);
|
|
central.writeUInt16LE(0, 32);
|
|
central.writeUInt16LE(0, 34);
|
|
central.writeUInt16LE(0, 36);
|
|
central.writeUInt32LE((entry.mode || 0o100644) * 0x10000, 38);
|
|
central.writeUInt32LE(offset, 42);
|
|
centrals.push(central, name);
|
|
|
|
offset += local.length + name.length + data.length;
|
|
}
|
|
const centralOffset = offset;
|
|
const centralDirectory = Buffer.concat(centrals);
|
|
const eocd = Buffer.alloc(22);
|
|
eocd.writeUInt32LE(0x06054b50, 0);
|
|
eocd.writeUInt16LE(0, 4);
|
|
eocd.writeUInt16LE(0, 6);
|
|
eocd.writeUInt16LE(entries.length, 8);
|
|
eocd.writeUInt16LE(entries.length, 10);
|
|
eocd.writeUInt32LE(centralDirectory.length, 12);
|
|
eocd.writeUInt32LE(centralOffset, 16);
|
|
eocd.writeUInt16LE(0, 20);
|
|
return Buffer.concat([...locals, centralDirectory, eocd]);
|
|
}
|