IcyZip

tools/sbom.mjs

Alle Dateien · Rohdatei öffnen

7983 bytes · SHA-256: 5fa4248d7f678dfe7537ad70d55b32f8dcaa468ca42e76ea89c84db9210f99ff

// Copyright 2026 Richard Andresik. SPDX-License-Identifier: Apache-2.0
// Build-time inventory: installed Debian packages, Node, npm/Yarn package
// metadata and every published review file. Unknown licenses stay NOASSERTION.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const root = fileURLToPath(new URL("../", import.meta.url));
const lock = JSON.parse(fs.readFileSync(path.join(root, "container-lock.json")));
const output = process.argv[2];
assert.equal(output, "/opt/icyzip-inventory", "Inventory output is the fixed image directory");
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
const json = filename => JSON.parse(fs.readFileSync(filename));
const sources = lock.files.map(name => {
    const filename = path.join(root, name);
    assert.equal(fs.realpathSync(filename), filename);
    const body = fs.readFileSync(filename);
    return { path: name, bytes: body.length, sha256: hash(body) };
});
const sourceId = hash(sources.map(file => file.sha256 + "  " + file.path + "\n").join(""));
const packages = [];
const licenses = {};
const relationships = [];
function add(name, version, location, license = "NOASSERTION", extra = {}) {
    const id = "SPDXRef-Package-" + hash(name + "\n" + version + "\n" + location).slice(0, 24);
    packages.push({ name, SPDXID: id, versionInfo: version, downloadLocation: "NOASSERTION",
        filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: license,
        copyrightText: "NOASSERTION", sourceInfo: location, ...extra });
    relationships.push({ spdxElementId: "SPDXRef-DOCUMENT", relationshipType: "DESCRIBES", relatedSpdxElement: id });
    return id;
}
const rows = execFileSync("dpkg-query", ["-W", "-f=${binary:Package}\t${Version}\t${Architecture}\t${db:Status-Status}\t${source:Package}\t${source:Version}\n"],
    { encoding: "utf8" }).trim().split("\n").sort();
for (const row of rows) {
    const [name, version, architecture, status, sourceName, sourceVersion] = row.split("\t");
    if (status !== "installed") continue;
    const basename = name.split(":")[0];
    const copyright = "/usr/share/doc/" + basename + "/copyright";
    licenses["deb:" + name] = fs.existsSync(copyright) ? [copyright] : [];
    add(basename, version, "Debian " + lock.debianRelease + " snapshot " + lock.debianSnapshot + "; " + copyright
        + "; corresponding source: https://snapshot.debian.org/package/" + encodeURIComponent(sourceName)
        + "/" + encodeURIComponent(sourceVersion) + "/",
        "NOASSERTION", { externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl",
            referenceLocator: "pkg:deb/debian/" + encodeURIComponent(basename) + "@" + encodeURIComponent(version)
                + "?arch=" + architecture + "&distro=debian-" + lock.debianVersion }] });
}
const nodeLicense = ["/usr/local/LICENSE", "/usr/local/share/doc/node/LICENSE"].find(name => fs.existsSync(name));
assert.ok(nodeLicense, "Node license must be retained from the official base image");
licenses.node = [nodeLicense];
add("node", process.versions.node, "Official Node image " + lock.base + "; " + nodeLicense, "MIT",
    { checksums: [{ algorithm: "SHA256", checksumValue: hash(fs.readFileSync(process.execPath)) }],
        externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl",
            referenceLocator: "pkg:generic/node@" + process.versions.node }] });
for (const [name, version] of Object.entries(process.versions).sort()) {
    if (name === "node") continue;
    add("node-component:" + name, version, "Bundled in Node; component notices: " + nodeLicense);
}
function nodePackages(directory) {
    const manifest = path.join(directory, "package.json");
    if (!fs.existsSync(manifest)) return;
    const item = json(manifest);
    if (typeof item.name !== "string" || typeof item.version !== "string") return;
    const candidates = fs.readdirSync(directory).filter(name => {
        const lower = name.toLowerCase();
        return ["license", "licence", "copying"].some(prefix => lower === prefix
            || lower.startsWith(prefix + ".") || lower.startsWith(prefix + "-"));
    }).map(name => path.join(directory, name));
    const found = candidates.filter(name => fs.existsSync(name) && fs.statSync(name).isFile());
    licenses["npm:" + item.name + "@" + item.version + ":" + directory] = found;
    add(item.name, item.version, directory + "; license files: " + found.join(", "),
        typeof item.license === "string" && !item.license.startsWith("SEE ") ? item.license : "NOASSERTION",
        { externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl",
            referenceLocator: "pkg:npm/" + item.name.split("/").map(encodeURIComponent).join("/") + "@" + encodeURIComponent(item.version) }] });
    const modules = path.join(directory, "node_modules");
    if (!fs.existsSync(modules)) return;
    for (const entry of fs.readdirSync(modules, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
        if (!entry.isDirectory()) continue;
        if (entry.name.startsWith("@")) {
            for (const scoped of fs.readdirSync(path.join(modules, entry.name), { withFileTypes: true })) {
                if (scoped.isDirectory()) nodePackages(path.join(modules, entry.name, scoped.name));
            }
        } else nodePackages(path.join(modules, entry.name));
    }
}
assert.equal(json("/usr/local/lib/node_modules/npm/package.json").version, lock.npm.version);
nodePackages("/usr/local/lib/node_modules/npm");
for (const entry of fs.readdirSync("/opt", { withFileTypes: true })) {
    if (entry.isDirectory() && entry.name.startsWith("yarn-v")) nodePackages("/opt/" + entry.name);
}
licenses["icyzip-e2ee-review"] = [path.join(root, "LICENSE")];
const reviewId = add("icyzip-e2ee-review", lock.version, "https://icyzip.com/open-source; sourceId sha256:" + sourceId,
    "Apache-2.0", { downloadLocation: "git+https://icyzip.com/open-source/icyzip-e2ee.git",
        copyrightText: "Copyright 2026 Richard Andresik" });
const files = sources.map((file, index) => {
    const id = "SPDXRef-File-" + index;
    relationships.push({ spdxElementId: reviewId, relationshipType: "CONTAINS", relatedSpdxElement: id });
    return { fileName: "./" + file.path, SPDXID: id,
        checksums: [{ algorithm: "SHA256", checksumValue: file.sha256 }], licenseConcluded: "NOASSERTION",
        licenseInfoInFiles: ["NOASSERTION"], copyrightText: "NOASSERTION" };
});
const sbom = { spdxVersion: "SPDX-2.3", dataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT",
    name: "icyzip-e2ee-review-" + lock.version + "-" + process.arch,
    documentNamespace: "https://icyzip.com/open-source/sbom/" + sourceId + "/" + process.arch,
    creationInfo: { created: new Date(lock.sourceDateEpoch * 1000).toISOString().split(".000").join(""),
        creators: ["Tool: icyzip-e2ee-inventory-1", "Organization: IcyZip (contact@icyzip.com)"],
        comment: "Package metadata inventory, not a vulnerability or license-compliance verdict. Debian copyright files and the complete Node third-party notices are retained. Bundled components lacking separate package metadata may not be individually enumerated." },
    packages, files, relationships };
fs.mkdirSync(output, { recursive: true });
for (const [name, value] of Object.entries({ "SBOM.spdx.json": sbom, "LICENSES.json": licenses, "sources.json": sources,
    "build.json": { version: lock.version, base: lock.base, debianSnapshot: lock.debianSnapshot,
        architecture: process.arch, sourceId, node: process.version } })) {
    fs.writeFileSync(path.join(output, name), JSON.stringify(value, null, 2) + "\n", { mode: 0o644 });
}
console.log(JSON.stringify({ inventory: output, packages: packages.length, sourceFiles: sources.length, sourceId }));