diff --git a/docs/LICENSING_AND_IP_TRANSFER.md b/docs/LICENSING_AND_IP_TRANSFER.md index 6661fe22d..94f52e3a9 100644 --- a/docs/LICENSING_AND_IP_TRANSFER.md +++ b/docs/LICENSING_AND_IP_TRANSFER.md @@ -1,6 +1,6 @@ # Noema Licensing and IP Transfer -- **Status:** In review on PR #71; policy/evidence baseline only, not legal clearance or protected-main acceptance. +- **Status:** Protected policy/evidence baseline; not legal clearance. Active PR #495 adds the npm dependency-license inventory generator described in section 4. - **Scope:** Noema source rights, package/container metadata, third-party obligations, contributor/IP provenance, release distribution, and acquisition transfer evidence. - **Decision authority:** Repository automation may detect, authenticate, inventory, and compare evidence. The outbound-license and transfer-rights decision belongs to the authorized **owner/legal** function. @@ -25,7 +25,7 @@ At minimum: 1. a root `LICENSE` file or explicitly approved custom-rights file contains controlling source terms when repository text grants rights; 2. `package.json` expresses the same declared posture without inventing broader rights; -3. release/container metadata that declares rights expresses the same approved posture for the exact artifact/revision; +3. release/container metadata that declares rights expresses the same approved posture for that exact artifact/revision; 4. decision owner, approval record, effective date, and scope are retained as acquisition evidence instead of inferred from a filename; 5. licensing changes are governance changes requiring reviewed source mutation, release-impact analysis, and evidence regeneration. @@ -49,7 +49,7 @@ Automation may verify syntax, paths, hashes, and declared relationships. It must ## 3. Protected exact-release `artifact_rights_metadata` contract -Protected source implements an exact-release rights receipt named `artifact_rights_metadata`. The acquisition-integrity work that introduced this boundary is already integrated on protected main; it is no longer active-PR-only evidence. The receipt is technical evidence, never legal authority. +Protected source implements an exact-release rights receipt named `artifact_rights_metadata`. The acquisition-integrity work that introduced this boundary is already integrated on protected main. The receipt is technical evidence, never legal authority. The authenticated receipt binds at least: @@ -85,6 +85,8 @@ Required evidence includes: - classifier/scanner tool identity and evidence source; - hashes/immutable identities tying license and NOTICE artifacts to the release/SBOM. +PR #495 adds `npm run release:dependency-license-inventory` for the npm lockfile slice. The generated `artifacts/release/dependency-licenses.json` is deterministic and bound to the SHA-256 of the exact `package-lock.json`. It records each non-root locked package path, package name, version, declared license, resolved artifact, integrity value, npm `dev`/`optional`/`devOptional`/`inBundle` classification authority, install-script authority (`hasInstallScript`), and present npm platform constraints (`cpu`/`os`). Present boolean authority must remain boolean; `devOptional`, `inBundle`, and `hasInstallScript` are preserved as `dev_optional`, `in_bundle`, and `has_install_script`. Present `cpu`/`os` constraints must be non-empty arrays of canonical non-empty strings, so acquisition evidence cannot silently discard or normalize platform applicability. Resolved artifact authority must be a canonical inspectable URI and must not embed URL passwords, non-conventional URL usernames, authentication/token/secret/key/signature/credential parameters in either query or fragment, or compact signed-URL `sig` parameters; the conventional `git` username is retained only for SSH-like Git URLs. Otherwise buyer/release evidence fails closed rather than retaining credential-bearing source metadata. Duplicate-key or malformed lockfiles and missing package identity/license fields fail closed. This inventory records package metadata; it does **not** establish compatibility, satisfy upstream NOTICE obligations, or create owner/legal permission. + Unknown or unresolved obligations fail closed for distribution/acquisition readiness. Vulnerability or provenance success does not prove license compatibility. ## 5. Contributor and IP ownership provenance @@ -143,16 +145,20 @@ owner/legal decision Each arrow requires independent identity/consistency evidence. A mismatch, missing required record, malformed/ambiguous JSON, or unresolved right is a fail-closed condition. -## 8. Current evidence and residual gap — 2026-08-17 +## 8. Current evidence and residual gap — 2026-08-23 -Protected `main` currently has no root `LICENSE` file. Protected `package.json` is `"private": true` and has no `license` field. That is evidence of an unresolved licensing/IP-transfer decision, not evidence for MIT, Apache-2.0, proprietary, source-available, or another posture. +Protected `main` at `e8a816e9f33ba1905b9fcc258798e07a3cd2657f` has no root `LICENSE` file. Protected `package.json` is `"private": true` and has no `license` field. That is evidence of an unresolved licensing/IP-transfer decision, not evidence for MIT, Apache-2.0, proprietary, source-available, or another posture. Current technical state is intentionally split: -- protected acquisition-integrity code already binds `artifact_rights_metadata` to repository/release/artifact identity and rejects duplicate decoded keys, malformed UTF-8/JSON, and inconsistent rights metadata before it can become acquisition evidence; -- Draft PR #407 preserves patch-validator image/supply-chain work reconstructed from historical predecessor #67. Any OCI-rights cleanup on that image lane remains active-PR evidence until #407 integrates; it is not protected-source truth yet. +- protected acquisition-integrity code binds `artifact_rights_metadata` to repository/release/artifact identity and rejects duplicate decoded keys, malformed UTF-8/JSON, and inconsistent rights metadata before it can become acquisition evidence; +- protected #407 integration supplies the patch-validator image/runtime/supply-chain implementation and its local SBOM/provenance verification boundary; +- protected #493 integration rejects placeholder, ambiguous, loopback and local-only pilot/commercial authority from saleable-readiness evidence; +- protected #494 integration binds Noema's immutable OIDC workflow trust anchor to the current audited central workflow source identity; +- active PR #495 adds reproducible npm lockfile license inventory generation, exact coverage ownership, canonical package-path and resolved-artifact authority, credential-free artifact metadata, npm dependency/distribution classification, install-script authority, and `cpu`/`os` platform applicability. Until it integrates, that generator remains active-PR truth, not protected-main truth; +- no technical artifact resolves compatibility, upstream NOTICE retention, contributor ownership/assignment, or the outbound-rights decision. -Issue #5 carries the owner/legal and ownership/assignment evidence gap. PR #71 supplies this canonical policy contract. Neither documentation nor technical enforcement makes legal clearance pass. +Issue #5 carries the acquisition owner/legal and ownership/assignment evidence gap. Issue #66 carries remaining release/publication, NOTICE and provenance/activation boundaries. Neither documentation nor technical enforcement makes legal clearance pass. ## 9. Non-goals @@ -171,6 +177,8 @@ Noema automation must not: GitHub. (2026). *Licensing a repository*. GitHub Docs. https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/licensing-a-repository +npm, Inc. (2026). *package-lock.json*. npm Docs. https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json + npm, Inc. (2026). *package.json — license*. npm Docs. https://docs.npmjs.com/files/package.json/ SPDX Workgroup. (2024). *SPDX Specification 3.0.1: Annex D — SPDX license expressions*. Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/annexes/spdx-license-expressions/ diff --git a/package.json b/package.json index f4fe59814..3c494479f 100644 --- a/package.json +++ b/package.json @@ -36,9 +36,10 @@ "smoke:check": "bash scripts/smoke-readiness.sh", "release:evidence": "node scripts/release-evidence.mjs", "release:publication-receipt": "node scripts/release-publication-receipt.mjs", + "release:dependency-license-inventory": "node scripts/dependency-license-inventory.mjs", "deployment:evidence": "node scripts/deployment-evidence.mjs", - "release:verify": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify && npm run acquisition:manifest && npm run acquisition:integrity", - "release:verify:strict": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify:strict && npm run acquisition:manifest && npm run acquisition:integrity", + "release:verify": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify && npm run release:dependency-license-inventory && npm run acquisition:manifest && npm run acquisition:integrity", + "release:verify:strict": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify:strict && npm run release:dependency-license-inventory && npm run acquisition:manifest && npm run acquisition:integrity", "security:scan": "npm audit --audit-level=high", "security:evidence": "node scripts/security-validation-evidence.mjs", "security:private-reporting-audit": "node scripts/private-vulnerability-reporting-audit.mjs", @@ -54,7 +55,7 @@ "patch-validator:image:verify-receipts": "node scripts/verify-patch-validator-image.mjs", "acquisition:deployment-evidence": "node scripts/acquisition-deployment-evidence-audit.mjs", "acquisition:integrity": "node scripts/acquisition-data-room-integrity-audit.mjs", - "acquisition:audit": "npm run acquisition:integrity && node scripts/acquisition-readiness-audit.mjs && npm run acquisition:deployment-evidence", + "acquisition:audit": "npm run release:dependency-license-inventory && npm run acquisition:integrity && node scripts/acquisition-readiness-audit.mjs && npm run acquisition:deployment-evidence", "acquisition:manifest": "node scripts/acquisition-data-room-manifest.mjs", "readiness:audit": "node scripts/saleable-readiness-audit.mjs" }, diff --git a/scripts/dependency-license-inventory.mjs b/scripts/dependency-license-inventory.mjs new file mode 100644 index 000000000..f645e4831 --- /dev/null +++ b/scripts/dependency-license-inventory.mjs @@ -0,0 +1,489 @@ +import { createHash } from "node:crypto"; +import { + closeSync, + constants, + existsSync, + lstatSync, + mkdirSync, + openSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, normalize, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readStableRegularFile } from "./lib/stable-file-evidence.mjs"; +import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; + +const DEFAULT_LOCK_PATH = "package-lock.json"; +const DEFAULT_OUTPUT_PATH = "artifacts/release/dependency-licenses.json"; +const MAXIMUM_LOCKFILE_BYTES = 4 * 1024 * 1024; +const MAXIMUM_NESTED_RESOLVED_DEPTH = 8; +const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const canonicalPackagePathPattern = /^(?:node_modules\/(?:@[^/]+\/(?!\.{1,2}(?:\/|$))[^/]+|(?!\.{1,2}(?:\/|$)|@)[^/]+))(?:\/node_modules\/(?:@[^/]+\/(?!\.{1,2}(?:\/|$))[^/]+|(?!\.{1,2}(?:\/|$)|@)[^/]+))*$/; +const forbiddenIdentityCodePointPattern = /[\p{Cc}\p{Cf}\p{Cs}]/u; +const forbiddenPackagePathCharacterPattern = /[\\\s]/u; +const malformedPercentEscapePattern = /%(?![0-9A-Fa-f]{2})/; +const supportedSriIntegrityPattern = /^(?:sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2})(?: sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2})*$/; +const sensitiveResolvedQueryKeyPattern = /(?:^|[^a-z0-9])(?:auth|authorization|token|secret|password|passwd|key|sig|signature|credential)(?:$|[^a-z0-9])/i; +const githubCredentialTokenPattern = /(^|[^a-z0-9])(github_pat_|gh[pousr]_)/i; +const npmCredentialTokenPattern = /(^|[^a-z0-9])npm_[a-z0-9]{36}([^a-z0-9]|$)/i; +const compactSensitiveResolvedQueryKeys = new Set([ + "apikey", + "accesskey", + "accesskeyid", + "clientsecret", + "sessiontoken", + "authtoken", + "accesstoken", + "refreshtoken", + "privatekey", + "secretkey", + "secretaccesskey", + "signingkey", +]); + +function hasStrongCredentialToken(value) { + return githubCredentialTokenPattern.test(value) || npmCredentialTokenPattern.test(value); +} + +function nonEmptyString(value, packagePath, field) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${packagePath}: non-empty ${field} required`); + } + if (value !== value.trim() || forbiddenIdentityCodePointPattern.test(value)) { + throw new Error(`${packagePath}: canonical ${field} required`); + } + return value; +} + +function canonicalIntegrity(value, packagePath) { + const integrity = nonEmptyString(value, packagePath, "integrity"); + if (!supportedSriIntegrityPattern.test(integrity)) { + throw new Error(`${packagePath}: supported SRI integrity required`); + } + for (const token of integrity.split(" ")) { + const separatorIndex = token.indexOf("-"); + const algorithm = token.slice(0, separatorIndex); + const digest = token.slice(separatorIndex + 1); + const expectedBytes = algorithm === "sha256" ? 32 : algorithm === "sha384" ? 48 : 64; + const decoded = Buffer.from(digest, "base64"); + if (decoded.length !== expectedBytes || decoded.toString("base64") !== digest) { + throw new Error(`${packagePath}: supported SRI integrity required`); + } + } + return integrity; +} + +function decodePercentTriplets(value) { + return value.replace( + /%([0-9A-Fa-f]{2})/g, + (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16)), + ); +} + +function isSensitiveResolvedParameterKey(key) { + let candidate = key; + while (true) { + const separatedCamelCase = candidate.replace(/([a-z0-9])([A-Z])/g, "$1_$2"); + const normalizedKey = separatedCamelCase.toLowerCase(); + if ( + sensitiveResolvedQueryKeyPattern.test(normalizedKey) + || compactSensitiveResolvedQueryKeys.has(normalizedKey.replace(/[_-]/g, "")) + ) { + return true; + } + const decodedCandidate = decodePercentTriplets(candidate); + if (decodedCandidate === candidate) return false; + candidate = decodedCandidate; + } +} + +function hasCredentialBearingUrlAuthority(parsed) { + const protocol = parsed.protocol.toLowerCase(); + const isSshLike = protocol === "ssh:" || protocol.endsWith("+ssh:"); + const conventionalGitSshUser = isSshLike && parsed.username === "git"; + return ( + parsed.password !== "" + || (parsed.username !== "" && !conventionalGitSshUser) + ); +} + +function hasCredentialBearingUrlPath(parsed) { + let candidate = parsed.pathname; + while (true) { + if (hasStrongCredentialToken(candidate)) return true; + const decodedCandidate = decodePercentTriplets(candidate); + if (decodedCandidate === candidate) return false; + candidate = decodedCandidate; + } +} + +function hasSensitiveNestedResolvedParameters(value) { + const pending = [{ value, depth: 0 }]; + + while (pending.length > 0) { + const { value: candidate, depth } = pending.pop(); + if (depth > MAXIMUM_NESTED_RESOLVED_DEPTH) return true; + if (hasStrongCredentialToken(candidate)) return true; + + let nestedUrl; + try { + nestedUrl = new URL(candidate); + } catch { + nestedUrl = null; + } + + if (nestedUrl) { + if ( + hasCredentialBearingUrlAuthority(nestedUrl) + || hasCredentialBearingUrlPath(nestedUrl) + ) return true; + for (const [nestedKey, nestedValue] of nestedUrl.searchParams) { + if (isSensitiveResolvedParameterKey(nestedKey)) return true; + pending.push({ value: nestedValue, depth: depth + 1 }); + } + const fragment = nestedUrl.hash.startsWith("#") + ? nestedUrl.hash.slice(1) + : nestedUrl.hash; + if (fragment !== "") { + pending.push({ value: fragment, depth: depth + 1 }); + } + } else { + for (const [nestedKey, nestedValue] of new URLSearchParams(candidate)) { + if (isSensitiveResolvedParameterKey(nestedKey)) return true; + pending.push({ value: nestedValue, depth: depth + 1 }); + } + + for (let index = 0; index < candidate.length; index += 1) { + if (candidate[index] !== "?" && candidate[index] !== "#") continue; + for (const [nestedKey, nestedValue] of new URLSearchParams(candidate.slice(index + 1))) { + if (isSensitiveResolvedParameterKey(nestedKey)) return true; + pending.push({ value: nestedValue, depth: depth + 1 }); + } + } + } + + const decodedCandidate = decodePercentTriplets(candidate); + if (decodedCandidate !== candidate) { + pending.push({ value: decodedCandidate, depth: depth + 1 }); + } + } + + return false; +} + +function assertCredentialFreeParameters(parameters, packagePath) { + for (const [key, value] of parameters) { + if ( + isSensitiveResolvedParameterKey(key) + || hasSensitiveNestedResolvedParameters(value) + ) { + throw new Error(`${packagePath}: credential-free resolved required`); + } + } +} + +function assertCredentialFreeFragmentValue(fragment, packagePath) { + if (hasStrongCredentialToken(fragment)) { + throw new Error(`${packagePath}: credential-free resolved required`); + } + assertCredentialFreeParameters(new URLSearchParams(fragment), packagePath); + const nestedQueryIndex = fragment.indexOf("?"); + if (nestedQueryIndex >= 0) { + assertCredentialFreeParameters( + new URLSearchParams(fragment.slice(nestedQueryIndex + 1)), + packagePath, + ); + } +} + +function assertCredentialFreeFragment(hash, packagePath) { + let fragment = hash.startsWith("#") ? hash.slice(1) : hash; + while (true) { + assertCredentialFreeFragmentValue(fragment, packagePath); + const decodedFragment = decodePercentTriplets(fragment); + if (decodedFragment === fragment) return; + fragment = decodedFragment; + } +} + +function assertCanonicalPercentEscapes(value, packagePath) { + if (malformedPercentEscapePattern.test(value)) { + throw new Error(`${packagePath}: canonical resolved artifact URI required`); + } +} + +function credentialFreeResolved(value, packagePath) { + const resolved = nonEmptyString(value, packagePath, "resolved"); + assertCanonicalPercentEscapes(resolved, packagePath); + let parsed; + try { + parsed = new URL(resolved); + } catch { + throw new Error(`${packagePath}: canonical resolved artifact URI required`); + } + if (parsed.href !== resolved) { + throw new Error(`${packagePath}: canonical resolved artifact URI required`); + } + if ( + hasCredentialBearingUrlAuthority(parsed) + || hasCredentialBearingUrlPath(parsed) + ) { + throw new Error(`${packagePath}: credential-free resolved required`); + } + assertCredentialFreeParameters(parsed.searchParams, packagePath); + assertCredentialFreeFragment(parsed.hash, packagePath); + return resolved; +} + +function optionalBoolean(value, packagePath, field) { + if (value === undefined) return false; + if (typeof value !== "boolean") { + throw new Error(`${packagePath}: boolean ${field} required when present`); + } + return value; +} + +function optionalCanonicalStringArray(value, packagePath, field) { + if (value === undefined) return undefined; + if ( + !Array.isArray(value) + || value.length === 0 + || value.some( + (entry) => + typeof entry !== "string" + || entry.trim() === "" + || entry !== entry.trim() + || forbiddenIdentityCodePointPattern.test(entry), + ) + || new Set(value).size !== value.length + ) { + throw new Error(`${packagePath}: canonical ${field} array required when present`); + } + return [...value]; +} + +function packageNameFromPath(packagePath) { + if (!packagePath.startsWith("node_modules/")) { + throw new Error(`${packagePath}: canonical node_modules package path required`); + } + if ( + forbiddenIdentityCodePointPattern.test(packagePath) + || forbiddenPackagePathCharacterPattern.test(packagePath) + || !canonicalPackagePathPattern.test(packagePath) + ) { + throw new Error(`${packagePath}: canonical package name required`); + } + const nestedMarker = "/node_modules/"; + const nestedIndex = packagePath.lastIndexOf(nestedMarker); + return nestedIndex >= 0 + ? packagePath.slice(nestedIndex + nestedMarker.length) + : packagePath.slice("node_modules/".length); +} + +function parseLockfile(lockBytes) { + let lock; + try { + lock = JSON.parse(lockBytes); + } catch { + throw new Error("package-lock.json must be valid JSON"); + } + if (hasDuplicateJsonObjectKeys(lockBytes)) { + throw new Error("package-lock.json must not contain duplicate object keys"); + } + if (!lock || typeof lock !== "object" || Array.isArray(lock)) { + throw new Error("package-lock.json object required"); + } + if (lock.lockfileVersion !== 3) { + throw new Error("package-lock.json lockfileVersion 3 required"); + } + if (!lock.packages || typeof lock.packages !== "object" || Array.isArray(lock.packages)) { + throw new Error("package-lock.json packages object required"); + } + return lock; +} + +function assertCanonicalEvidencePath(path, label) { + if (typeof path !== "string" || path.length === 0 || normalize(path) !== path) { + throw new Error(`dependency license inventory canonical ${label} path required`); + } +} + +function assertPathParents(path, label) { + let parentPath = dirname(resolve(path)); + while (true) { + if (existsSync(parentPath) && lstatSync(parentPath).isSymbolicLink()) { + throw new Error( + `dependency license inventory ${label} parent must not be a symlink: ${parentPath}`, + ); + } + const nextParent = dirname(parentPath); + if (nextParent === parentPath) return; + parentPath = nextParent; + } +} + +function readEvidenceFile(inputPath) { + assertCanonicalEvidencePath(inputPath, "input"); + assertPathParents(inputPath, "input"); + const bytes = readStableRegularFile( + inputPath, + "dependency license inventory input", + MAXIMUM_LOCKFILE_BYTES, + ); + assertPathParents(inputPath, "input"); + try { + return fatalUtf8Decoder.decode(bytes); + } catch { + throw new Error("package-lock.json must be valid UTF-8"); + } +} + +function removeSafeExistingOutput(outputPath) { + if (!existsSync(outputPath)) return; + const metadata = lstatSync(outputPath); + if (metadata.isSymbolicLink()) { + throw new Error(`dependency license inventory output must not be a symlink: ${outputPath}`); + } + if (!metadata.isFile()) { + throw new Error(`dependency license inventory output must be a regular file: ${outputPath}`); + } + if (metadata.nlink !== 1) { + throw new Error(`dependency license inventory output must have exactly one link: ${outputPath}`); + } + unlinkSync(outputPath); +} + +function writeEvidenceFile(outputPath, content) { + removeSafeExistingOutput(outputPath); + const descriptor = openSync( + outputPath, + constants.O_WRONLY + | constants.O_CREAT + | constants.O_EXCL + | constants.O_NOFOLLOW, + 0o600, + ); + try { + writeFileSync(descriptor, content, "utf8"); + } finally { + closeSync(descriptor); + } +} + +export function buildDependencyLicenseInventory( + lockBytes, + { sourcePath = DEFAULT_LOCK_PATH } = {}, +) { + if (typeof lockBytes !== "string") { + throw new TypeError("package-lock.json bytes must be a string"); + } + if (typeof sourcePath !== "string" || sourcePath.trim() === "") { + throw new TypeError("package-lock.json source path must be a non-empty string"); + } + if ( + sourcePath !== sourcePath.trim() + || normalize(sourcePath) !== sourcePath + || forbiddenIdentityCodePointPattern.test(sourcePath) + ) { + throw new TypeError("package-lock.json source path must be canonical"); + } + const lock = parseLockfile(lockBytes); + const packages = Object.keys(lock.packages) + .filter((packagePath) => packagePath !== "") + .sort() + .map((packagePath) => { + const rawEntry = lock.packages[packagePath]; + if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) { + throw new Error(`${packagePath}: package object required`); + } + const entry = rawEntry; + const devOptional = optionalBoolean(entry.devOptional, packagePath, "devOptional"); + const inBundle = optionalBoolean(entry.inBundle, packagePath, "inBundle"); + const hasInstallScript = optionalBoolean( + entry.hasInstallScript, + packagePath, + "hasInstallScript", + ); + const cpu = optionalCanonicalStringArray(entry.cpu, packagePath, "cpu"); + const os = optionalCanonicalStringArray(entry.os, packagePath, "os"); + return { + package_path: packagePath, + name: packageNameFromPath(packagePath), + version: nonEmptyString(entry.version, packagePath, "version"), + license: nonEmptyString(entry.license, packagePath, "license"), + resolved: credentialFreeResolved(entry.resolved, packagePath), + integrity: canonicalIntegrity(entry.integrity, packagePath), + dev: optionalBoolean(entry.dev, packagePath, "dev"), + optional: optionalBoolean(entry.optional, packagePath, "optional"), + ...(entry.devOptional === undefined ? {} : { dev_optional: devOptional }), + ...(entry.inBundle === undefined ? {} : { in_bundle: inBundle }), + ...(entry.hasInstallScript === undefined + ? {} + : { has_install_script: hasInstallScript }), + ...(cpu === undefined ? {} : { cpu }), + ...(os === undefined ? {} : { os }), + }; + }); + + return { + schema_version: 1, + source: { + path: sourcePath, + sha256: createHash("sha256").update(lockBytes).digest("hex"), + lockfile_version: 3, + }, + packages, + }; +} + +export function generateDependencyLicenseInventory({ + lockPath = DEFAULT_LOCK_PATH, + outputPath = DEFAULT_OUTPUT_PATH, +} = {}) { + const lockBytes = readEvidenceFile(lockPath); + const inventory = buildDependencyLicenseInventory(lockBytes, { sourcePath: lockPath }); + assertCanonicalEvidencePath(outputPath, "output"); + assertPathParents(outputPath, "output"); + mkdirSync(dirname(outputPath), { recursive: true }); + assertPathParents(outputPath, "output"); + writeEvidenceFile(outputPath, `${JSON.stringify(inventory, null, 2)}\n`); + return inventory; +} + +export function main(options = {}) { + const lockPath = DEFAULT_LOCK_PATH; + const outputPath = DEFAULT_OUTPUT_PATH; + const generateInventory = + options.generate_inventory ?? generateDependencyLicenseInventory; + const writeOutput = options.write_output ?? ((value) => process.stdout.write(value)); + const inventory = generateInventory({ lockPath, outputPath }); + writeOutput( + `dependency license inventory: ${inventory.packages.length} packages -> ${outputPath}\n`, + ); + return inventory; +} + +export function startCli(options = {}) { + const execute = options.execute ?? main; + const writeError = options.write_error ?? ((value) => process.stderr.write(value)); + const setExitCode = options.set_exit_code ?? ((code) => { + process.exitCode = code; + }); + try { + return execute(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeError(`dependency license inventory failed: ${message}\n`); + setExitCode(1); + return undefined; + } +} + +export function runIfDirect(metaUrl, argv, execute) { + if (!argv[1] || metaUrl !== pathToFileURL(resolve(argv[1])).href) return false; + execute(); + return true; +} + +runIfDirect(import.meta.url, process.argv, startCli); \ No newline at end of file diff --git a/scripts/lib/acquisition-data-room-catalog.mjs b/scripts/lib/acquisition-data-room-catalog.mjs index c1eeb3caf..ea4a77f63 100644 --- a/scripts/lib/acquisition-data-room-catalog.mjs +++ b/scripts/lib/acquisition-data-room-catalog.mjs @@ -12,11 +12,26 @@ function requiredFile(id, category, path) { }); } +/** Build one immutable generated evidence entry required only for the final buyer gate. */ +function finalEvidenceFile(id, category, path, validatedBy, statusMeaning) { + return Object.freeze({ + id, + category, + kind: "file", + path, + required: false, + requiredForFinalGate: true, + validatedBy, + statusMeaning, + }); +} + /** - * Noema-specific product evidence layered on top of the hardened acquisition - * integrity catalog. The integrity verifier receives this exact composed - * catalog from both production entrypoints, so gateway evidence remains inside - * the same strict immutable-entry validation boundary as the base catalog. + * Noema-specific product and transfer evidence layered on top of the hardened + * acquisition integrity catalog. The integrity verifier receives this exact + * composed catalog from both production entrypoints, so these entries remain + * inside the same strict immutable-entry validation boundary as the base + * catalog without treating generated licensing metadata as legal authority. */ export const DATA_ROOM_CATALOG = Object.freeze([ ...BASE_DATA_ROOM_CATALOG, @@ -30,4 +45,11 @@ export const DATA_ROOM_CATALOG = Object.freeze([ "product", "docs/orchestrator-gateway-consumer-contract.md", ), -]); + finalEvidenceFile( + "dependency-license-inventory", + "transfer", + "artifacts/release/dependency-licenses.json", + "npm run release:dependency-license-inventory", + "generated file presence only; owner/legal compatibility and NOTICE obligations remain independent evidence", + ), +]); \ No newline at end of file diff --git a/test/acquisition-data-room-catalog.test.ts b/test/acquisition-data-room-catalog.test.ts index 67cc43889..043624240 100644 --- a/test/acquisition-data-room-catalog.test.ts +++ b/test/acquisition-data-room-catalog.test.ts @@ -3,7 +3,7 @@ import { DATA_ROOM_CATALOG as BASE_DATA_ROOM_CATALOG } from "../scripts/lib/acqu import { DATA_ROOM_CATALOG } from "../scripts/lib/acquisition-data-room-catalog.mjs"; describe("composed acquisition data-room catalog", () => { - it("preserves the hardened base catalog and appends immutable required gateway evidence", () => { + it("preserves the hardened base catalog and appends immutable Noema evidence", () => { expect(Object.isFrozen(DATA_ROOM_CATALOG)).toBe(true); expect(DATA_ROOM_CATALOG.slice(0, BASE_DATA_ROOM_CATALOG.length)).toEqual( BASE_DATA_ROOM_CATALOG, @@ -27,7 +27,18 @@ describe("composed acquisition data-room catalog", () => { required: true, requiredForFinalGate: true, }, + { + id: "dependency-license-inventory", + category: "transfer", + kind: "file", + path: "artifacts/release/dependency-licenses.json", + required: false, + requiredForFinalGate: true, + validatedBy: "npm run release:dependency-license-inventory", + statusMeaning: + "generated file presence only; owner/legal compatibility and NOTICE obligations remain independent evidence", + }, ]); expect(appendedEntries.every((entry) => Object.isFrozen(entry))).toBe(true); }); -}); +}); \ No newline at end of file diff --git a/test/dependency-license-inventory-canonical-identities.test.ts b/test/dependency-license-inventory-canonical-identities.test.ts new file mode 100644 index 000000000..ea5666047 --- /dev/null +++ b/test/dependency-license-inventory-canonical-identities.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockWithIdentity(field: "version" | "license" | "resolved" | "integrity", value: string) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + [field]: value, + }, + }, + }); +} + +function lockWithFlag(field: "dev" | "optional", value: unknown) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + [field]: value, + }, + }, + }); +} + +describe("dependency license inventory canonical identities", () => { + it.each([ + ["version", " 1.0.0"], + ["license", "MIT "], + ["resolved", "\thttps://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz"], + ["integrity", "sha512-alpha\n"], + ] as const)("rejects non-canonical surrounding whitespace in %s", (field, value) => { + expect(() => buildDependencyLicenseInventory(lockWithIdentity(field, value))).toThrow( + `node_modules/alpha: canonical ${field} required`, + ); + }); + + it.each([ + ["version", "1.0.0\nforged"], + ["license", "MIT\u0000Apache-2.0"], + ["resolved", "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz\rforged"], + ["integrity", "sha512-alpha\u007fforged"], + ] as const)("rejects embedded ASCII control characters in %s", (field, value) => { + expect(() => buildDependencyLicenseInventory(lockWithIdentity(field, value))).toThrow( + `node_modules/alpha: canonical ${field} required`, + ); + }); + + it.each([ + ["version", "1.0.0\u0085forged"], + ["license", "MIT\u202eApache-2.0"], + ["resolved", "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz\u2066forged"], + ["integrity", "sha512-alpha\ud800forged"], + ] as const)("rejects Unicode control/format/surrogate spoofing in %s", (field, value) => { + expect(() => buildDependencyLicenseInventory(lockWithIdentity(field, value))).toThrow( + `node_modules/alpha: canonical ${field} required`, + ); + }); + + it.each([ + "not-an-sri", + "md5-deadbeef", + "sha512-", + ])("rejects non-SRI package integrity authority %j", (integrity) => { + expect(() => buildDependencyLicenseInventory(lockWithIdentity("integrity", integrity))).toThrow( + "node_modules/alpha: supported SRI integrity required", + ); + }); + + it.each([ + ["dev", "true"], + ["dev", 1], + ["optional", "false"], + ["optional", 0], + ] as const)("rejects non-boolean %s classification", (field, value) => { + expect(() => buildDependencyLicenseInventory(lockWithFlag(field, value))).toThrow( + `node_modules/alpha: boolean ${field} required when present`, + ); + }); +}); diff --git a/test/dependency-license-inventory-cli.test.ts b/test/dependency-license-inventory-cli.test.ts new file mode 100644 index 000000000..bb355ffa1 --- /dev/null +++ b/test/dependency-license-inventory-cli.test.ts @@ -0,0 +1,260 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + main, + runIfDirect, + startCli, +} from "../scripts/dependency-license-inventory.mjs"; + +const inventory = { + schema_version: 1, + source: { + path: "package-lock.json", + sha256: "a".repeat(64), + lockfile_version: 3, + }, + packages: [{ name: "alpha" }], +}; + +const originalLockPath = process.env.NOEMA_DEPENDENCY_LICENSE_LOCK_PATH; +const originalOutputPath = process.env.NOEMA_DEPENDENCY_LICENSE_OUTPUT_PATH; +const originalExitCode = process.exitCode; +const temporaryRoots: string[] = []; + +function restoreEnvironmentVariable(name: string, value: string | undefined) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +function validLockfile() { + return { + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + }, + }, + }; +} + +afterEach(() => { + restoreEnvironmentVariable( + "NOEMA_DEPENDENCY_LICENSE_LOCK_PATH", + originalLockPath, + ); + restoreEnvironmentVariable( + "NOEMA_DEPENDENCY_LICENSE_OUTPUT_PATH", + originalOutputPath, + ); + process.exitCode = originalExitCode; + while (temporaryRoots.length > 0) { + const root = temporaryRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +describe("dependency license inventory CLI", () => { + it("does not let ambient environment variables redirect release evidence paths", () => { + const generateInventory = vi.fn(() => inventory); + const writeOutput = vi.fn(); + + const result = main({ + env: { + NOEMA_DEPENDENCY_LICENSE_LOCK_PATH: "fixtures/untrusted-lock.json", + NOEMA_DEPENDENCY_LICENSE_OUTPUT_PATH: "artifacts/untrusted/licenses.json", + }, + generate_inventory: generateInventory, + write_output: writeOutput, + }); + + expect(result).toBe(inventory); + expect(generateInventory).toHaveBeenCalledWith({ + lockPath: "package-lock.json", + outputPath: "artifacts/release/dependency-licenses.json", + }); + expect(writeOutput).toHaveBeenCalledWith( + "dependency license inventory: 1 packages -> artifacts/release/dependency-licenses.json\n", + ); + }); + + it("uses canonical repository paths when no path override is present", () => { + const generateInventory = vi.fn(() => inventory); + const writeOutput = vi.fn(); + + main({ generate_inventory: generateInventory, write_output: writeOutput }); + + expect(generateInventory).toHaveBeenCalledWith({ + lockPath: "package-lock.json", + outputPath: "artifacts/release/dependency-licenses.json", + }); + }); + + it("uses canonical relative paths with the default generator even when ambient overrides exist", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-cli-")); + temporaryRoots.push(root); + const originalDirectory = process.cwd(); + writeFileSync( + join(root, "package-lock.json"), + `${JSON.stringify(validLockfile(), null, 2)}\n`, + "utf8", + ); + process.env.NOEMA_DEPENDENCY_LICENSE_LOCK_PATH = join(root, "untrusted-lock.json"); + process.env.NOEMA_DEPENDENCY_LICENSE_OUTPUT_PATH = join(root, "untrusted-output.json"); + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + try { + process.chdir(root); + const result = main(); + + expect(result.source.path).toBe("package-lock.json"); + expect(existsSync(join(root, "artifacts", "release", "dependency-licenses.json"))).toBe(true); + expect(existsSync(join(root, "untrusted-output.json"))).toBe(false); + expect(stdout).toHaveBeenCalledWith( + "dependency license inventory: 1 packages -> artifacts/release/dependency-licenses.json\n", + ); + } finally { + process.chdir(originalDirectory); + } + }); + + it("refuses a symlinked canonical artifacts directory instead of writing release evidence outside the repository path", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-cli-parent-link-")); + temporaryRoots.push(root); + const originalDirectory = process.cwd(); + const redirectedArtifacts = join(root, "redirected-artifacts"); + mkdirSync(redirectedArtifacts); + symlinkSync(redirectedArtifacts, join(root, "artifacts"), "dir"); + writeFileSync( + join(root, "package-lock.json"), + `${JSON.stringify(validLockfile(), null, 2)}\n`, + "utf8", + ); + + try { + process.chdir(root); + expect(() => main()).toThrow(); + expect( + existsSync( + join(redirectedArtifacts, "release", "dependency-licenses.json"), + ), + ).toBe(false); + } finally { + process.chdir(originalDirectory); + } + }); + + it("refuses a symlinked canonical release directory after accepting a real artifacts directory", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-cli-release-link-")); + temporaryRoots.push(root); + const originalDirectory = process.cwd(); + const artifacts = join(root, "artifacts"); + const redirectedRelease = join(root, "redirected-release"); + mkdirSync(artifacts); + mkdirSync(redirectedRelease); + symlinkSync(redirectedRelease, join(artifacts, "release"), "dir"); + writeFileSync( + join(root, "package-lock.json"), + `${JSON.stringify(validLockfile(), null, 2)}\n`, + "utf8", + ); + + try { + process.chdir(root); + expect(() => main()).toThrow(); + expect(existsSync(join(redirectedRelease, "dependency-licenses.json"))).toBe(false); + } finally { + process.chdir(originalDirectory); + } + }); + + it("tests direct-entry dispatch independently from CLI error handling", () => { + const execute = vi.fn(); + expect(runIfDirect("file:///tmp/a.mjs", ["node"], execute)).toBe(false); + expect(runIfDirect("file:///tmp/a.mjs", ["node", "/tmp/b.mjs"], execute)).toBe(false); + expect( + runIfDirect(pathToFileURL(resolve("/tmp/a.mjs")).href, ["node", "/tmp/a.mjs"], execute), + ).toBe(true); + expect(execute).toHaveBeenCalledOnce(); + }); + + it("returns successful CLI execution and converts injected failures into nonzero status", () => { + const success = vi.fn(() => inventory); + expect(startCli({ execute: success })).toBe(inventory); + + const writeError = vi.fn(); + const setExitCode = vi.fn(); + expect( + startCli({ + execute: () => { + throw new Error("bad failure"); + }, + write_error: writeError, + set_exit_code: setExitCode, + }), + ).toBeUndefined(); + expect(writeError).toHaveBeenCalledWith( + "dependency license inventory failed: bad failure\n", + ); + expect(setExitCode).toHaveBeenCalledWith(1); + + writeError.mockClear(); + expect( + startCli({ + execute: () => { + throw "string failure"; + }, + write_error: writeError, + set_exit_code: setExitCode, + }), + ).toBeUndefined(); + expect(writeError).toHaveBeenCalledWith( + "dependency license inventory failed: string failure\n", + ); + }); + + it("fails closed on a missing canonical lockfile even if an ambient alternate lock exists", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-cli-fail-")); + temporaryRoots.push(root); + const originalDirectory = process.cwd(); + const alternateLock = join(root, "alternate-lock.json"); + writeFileSync( + alternateLock, + `${JSON.stringify(validLockfile(), null, 2)}\n`, + "utf8", + ); + process.env.NOEMA_DEPENDENCY_LICENSE_LOCK_PATH = alternateLock; + process.env.NOEMA_DEPENDENCY_LICENSE_OUTPUT_PATH = join(root, "output.json"); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + try { + process.chdir(root); + expect(startCli()).toBeUndefined(); + + expect(process.exitCode).toBe(1); + expect(stderr).toHaveBeenCalledOnce(); + expect(String(stderr.mock.calls[0][0])).toContain( + "dependency license inventory failed:", + ); + expect(existsSync(join(root, "output.json"))).toBe(false); + } finally { + process.chdir(originalDirectory); + } + }); +}); \ No newline at end of file diff --git a/test/dependency-license-inventory-dev-optional.test.ts b/test/dependency-license-inventory-dev-optional.test.ts new file mode 100644 index 000000000..140b023da --- /dev/null +++ b/test/dependency-license-inventory-dev-optional.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockWithDevOptional(devOptional: unknown) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + devOptional, + }, + }, + }); +} + +describe("dependency license inventory devOptional classification", () => { + it("preserves npm devOptional authority instead of collapsing it to production", () => { + const inventory = buildDependencyLicenseInventory(lockWithDevOptional(true)); + + expect(inventory.packages[0]).toMatchObject({ + dev: false, + optional: false, + dev_optional: true, + }); + }); + + it.each(["true", 1, null])( + "rejects malformed present devOptional classification %j", + (devOptional) => { + expect(() => buildDependencyLicenseInventory(lockWithDevOptional(devOptional))).toThrow( + "node_modules/alpha: boolean devOptional required when present", + ); + }, + ); +}); diff --git a/test/dependency-license-inventory-in-bundle.test.ts b/test/dependency-license-inventory-in-bundle.test.ts new file mode 100644 index 000000000..49ade8f21 --- /dev/null +++ b/test/dependency-license-inventory-in-bundle.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockWithInBundle(inBundle: unknown) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + inBundle, + }, + }, + }); +} + +describe("dependency license inventory inBundle distribution classification", () => { + it.each([true, false])( + "preserves npm inBundle=%s authority for acquisition evidence", + (inBundle) => { + const inventory = buildDependencyLicenseInventory(lockWithInBundle(inBundle)); + + expect(inventory.packages[0]).toMatchObject({ + in_bundle: inBundle, + }); + }, + ); + + it.each(["true", 1, null])( + "rejects malformed present inBundle classification %j", + (inBundle) => { + expect(() => buildDependencyLicenseInventory(lockWithInBundle(inBundle))).toThrow( + "node_modules/alpha: boolean inBundle required when present", + ); + }, + ); +}); diff --git a/test/dependency-license-inventory-install-script.test.ts b/test/dependency-license-inventory-install-script.test.ts new file mode 100644 index 000000000..52357edbb --- /dev/null +++ b/test/dependency-license-inventory-install-script.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockWithInstallScript(hasInstallScript: unknown) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + hasInstallScript, + }, + }, + }); +} + +describe("dependency license inventory install-script authority", () => { + it.each([true, false])( + "preserves npm hasInstallScript=%s authority for buyer dependency evidence", + (hasInstallScript) => { + const inventory = buildDependencyLicenseInventory(lockWithInstallScript(hasInstallScript)); + + expect(inventory.packages[0]).toMatchObject({ + has_install_script: hasInstallScript, + }); + }, + ); + + it.each(["true", 1, null])( + "rejects malformed present hasInstallScript authority %j", + (hasInstallScript) => { + expect(() => buildDependencyLicenseInventory(lockWithInstallScript(hasInstallScript))).toThrow( + "node_modules/alpha: boolean hasInstallScript required when present", + ); + }, + ); +}); diff --git a/test/dependency-license-inventory-npm-token.test.ts b/test/dependency-license-inventory-npm-token.test.ts new file mode 100644 index 000000000..cf04e7d8e --- /dev/null +++ b/test/dependency-license-inventory-npm-token.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +const npmToken = "npm_abcdefghijklmnopqrstuvwxyz0123456789"; + +function lockWithResolved(resolved: string) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved, + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + }, + }, + }); +} + +describe("dependency license inventory npm credential tokens", () => { + it.each([ + `https://registry.example/download/${npmToken}/alpha.tgz`, + `https://registry.example/alpha.tgz?mirror=${npmToken}`, + `https://registry.example/alpha.tgz#${npmToken}`, + "https://registry.example/alpha.tgz?mirror=%6epm_abcdefghijklmnopqrstuvwxyz0123456789", + ])("rejects strong npm token material in resolved artifact authority: %s", (resolved) => { + expect(() => buildDependencyLicenseInventory(lockWithResolved(resolved))).toThrow( + "node_modules/alpha: credential-free resolved required", + ); + }); + + it("preserves an ordinary npm-prefixed metadata word that is not a token", () => { + const resolved = "https://registry.example/alpha.tgz?channel=npm_package_metadata"; + const inventory = buildDependencyLicenseInventory(lockWithResolved(resolved)); + expect(inventory.packages[0].resolved).toBe(resolved); + }); +}); diff --git a/test/dependency-license-inventory-package-path-controls.test.ts b/test/dependency-license-inventory-package-path-controls.test.ts new file mode 100644 index 000000000..9b64b61b4 --- /dev/null +++ b/test/dependency-license-inventory-package-path-controls.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockWithPackagePath(packagePath: string) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + [packagePath]: { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + }, + }, + }); +} + +describe("dependency license inventory package-path authority", () => { + it.each([ + "node_modules/alpha\nforged", + "node_modules/alpha\u0085forged", + "node_modules/alpha\u202eforged", + "node_modules/@scope/alpha\u2066forged", + ])("rejects control/format-spoofed package path %j", (packagePath) => { + expect(() => buildDependencyLicenseInventory(lockWithPackagePath(packagePath))).toThrow( + `${packagePath}: canonical package name required`, + ); + }); + + it.each([ + "node_modules/alpha ", + "node_modules/ alpha", + "node_modules/alpha\\nested", + "node_modules/@scope/alpha ", + "node_modules/@scope /alpha", + ])("rejects whitespace/backslash-ambiguous package path %j", (packagePath) => { + expect(() => buildDependencyLicenseInventory(lockWithPackagePath(packagePath))).toThrow( + `${packagePath}: canonical package name required`, + ); + }); +}); diff --git a/test/dependency-license-inventory-parent-symlink.test.ts b/test/dependency-license-inventory-parent-symlink.test.ts new file mode 100644 index 000000000..a473b7dda --- /dev/null +++ b/test/dependency-license-inventory-parent-symlink.test.ts @@ -0,0 +1,117 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +const temporaryRoots: string[] = []; + +function fixtureLockBytes() { + return `${JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + license: "MIT", + }, + }, + })}\n`; +} + +afterEach(() => { + while (temporaryRoots.length > 0) { + const root = temporaryRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } +}); + +describe("dependency license inventory parent paths", () => { + it("refuses a symlinked custom output parent instead of writing through it", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-custom-parent-")); + temporaryRoots.push(root); + const lockPath = join(root, "package-lock.json"); + const redirectedTarget = join(root, "redirected-target"); + const redirectedParent = join(root, "redirected-parent"); + const outputPath = join(redirectedParent, "dependency-licenses.json"); + + mkdirSync(redirectedTarget); + symlinkSync(redirectedTarget, redirectedParent, "dir"); + writeFileSync(lockPath, fixtureLockBytes(), "utf8"); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow(/output parent must not be a symlink/); + expect(existsSync(join(redirectedTarget, "dependency-licenses.json"))).toBe(false); + }); + + it("refuses a symlinked lockfile parent instead of authenticating redirected bytes", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-input-parent-")); + temporaryRoots.push(root); + const trustedParent = join(root, "trusted-input"); + const redirectedTarget = join(root, "redirected-input"); + const lockPath = join(trustedParent, "package-lock.json"); + const outputPath = join(root, "dependency-licenses.json"); + + mkdirSync(redirectedTarget); + writeFileSync(join(redirectedTarget, "package-lock.json"), fixtureLockBytes(), "utf8"); + symlinkSync(redirectedTarget, trustedParent, "dir"); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow(/input parent must not be a symlink/); + expect(existsSync(outputPath)).toBe(false); + }); + + it("refuses an output path whose dot segment changes meaning after symlink traversal", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-output-dot-segment-")); + const outside = mkdtempSync(join(tmpdir(), "noema-license-output-outside-")); + temporaryRoots.push(root, outside); + const lockPath = join(root, "package-lock.json"); + const redirectedParent = join(root, "redirected-parent"); + const redirectedTarget = join(outside, "nested"); + const escapedOutput = join(outside, "dependency-licenses.json"); + const outputPath = `${redirectedParent}/../dependency-licenses.json`; + + mkdirSync(redirectedTarget); + symlinkSync(redirectedTarget, redirectedParent, "dir"); + writeFileSync(lockPath, fixtureLockBytes(), "utf8"); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow(/canonical output path required/); + expect(existsSync(escapedOutput)).toBe(false); + }); + + it("refuses an input path whose dot segment changes meaning after symlink traversal", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-input-dot-segment-")); + const outside = mkdtempSync(join(tmpdir(), "noema-license-input-outside-")); + temporaryRoots.push(root, outside); + const redirectedParent = join(root, "redirected-parent"); + const redirectedTarget = join(outside, "nested"); + const escapedLock = join(outside, "package-lock.json"); + const lockPath = `${redirectedParent}/../package-lock.json`; + const outputPath = join(root, "dependency-licenses.json"); + + mkdirSync(redirectedTarget); + symlinkSync(redirectedTarget, redirectedParent, "dir"); + writeFileSync(escapedLock, fixtureLockBytes(), "utf8"); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow(/canonical input path required/); + expect(existsSync(outputPath)).toBe(false); + }); +}); diff --git a/test/dependency-license-inventory-platform.test.ts b/test/dependency-license-inventory-platform.test.ts new file mode 100644 index 000000000..a4c72eda6 --- /dev/null +++ b/test/dependency-license-inventory-platform.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockWithPlatformAuthority({ cpu, os }: { cpu?: unknown; os?: unknown }) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/platform-package": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/platform-package/-/platform-package-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + ...(cpu === undefined ? {} : { cpu }), + ...(os === undefined ? {} : { os }), + }, + }, + }); +} + +describe("dependency license inventory platform authority", () => { + it("preserves npm cpu/os constraints for buyer dependency evidence", () => { + const inventory = buildDependencyLicenseInventory( + lockWithPlatformAuthority({ cpu: ["x64", "arm64"], os: ["darwin", "linux"] }), + ); + + expect(inventory.packages[0]).toMatchObject({ + cpu: ["x64", "arm64"], + os: ["darwin", "linux"], + }); + }); + + it("omits absent cpu/os authority rather than inventing applicability", () => { + const inventory = buildDependencyLicenseInventory(lockWithPlatformAuthority({})); + + expect(inventory.packages[0]).not.toHaveProperty("cpu"); + expect(inventory.packages[0]).not.toHaveProperty("os"); + }); + + it.each([ + { field: "cpu", value: "x64" }, + { field: "os", value: "linux" }, + { field: "cpu", value: [] }, + { field: "os", value: [""] }, + { field: "cpu", value: ["x64", 1] }, + { field: "os", value: ["linux", " linux"] }, + { field: "cpu", value: ["x64\u0000"] }, + { field: "cpu", value: ["x64", "x64"] }, + { field: "os", value: ["linux", "linux"] }, + ])("rejects malformed present $field authority", ({ field, value }) => { + const lock = lockWithPlatformAuthority({ [field]: value }); + expect(() => buildDependencyLicenseInventory(lock)).toThrow( + `node_modules/platform-package: canonical ${field} array required when present`, + ); + }); +}); diff --git a/test/dependency-license-inventory-release-wiring.test.ts b/test/dependency-license-inventory-release-wiring.test.ts new file mode 100644 index 000000000..46f0a82f1 --- /dev/null +++ b/test/dependency-license-inventory-release-wiring.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("release dependency-license evidence wiring", () => { + it.each(["release:verify", "release:verify:strict"])( + "%s generates the exact lockfile inventory before acquisition manifest materialization", + (scriptName) => { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ); + const script = packageJson.scripts[scriptName]; + + expect(script).toContain("npm run release:dependency-license-inventory"); + expect(script.indexOf("npm run release:dependency-license-inventory")).toBeLessThan( + script.indexOf("npm run acquisition:manifest"), + ); + }, + ); + + it("acquisition:audit refreshes deterministic license evidence before integrity verification", () => { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ); + const script = packageJson.scripts["acquisition:audit"]; + + expect(script).toContain("npm run release:dependency-license-inventory"); + expect(script.indexOf("npm run release:dependency-license-inventory")).toBeLessThan( + script.indexOf("npm run acquisition:integrity"), + ); + }); +}); diff --git a/test/dependency-license-inventory-resolved-credentials.test.ts b/test/dependency-license-inventory-resolved-credentials.test.ts new file mode 100644 index 000000000..3eb4b295a --- /dev/null +++ b/test/dependency-license-inventory-resolved-credentials.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockWithResolved(resolved: string) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved, + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + }, + }, + }); +} + +describe("dependency license inventory resolved artifact credentials", () => { + it.each([ + "https://token@registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + "https://user:secret@registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + "https://registry.example/download/ghp_EXAMPLEVALUE123456/alpha.tgz", + "https://registry.example/download/%67hp_EXAMPLEVALUE123456/alpha.tgz", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?token=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?access_token=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?%2574oken=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?foo[token]=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?auth.token=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?apiKey=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?apikey=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?accessKeyId=AKIAEXAMPLE", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?accesskeyid=AKIAEXAMPLE", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?sessionToken=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?sessiontoken=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?clientSecret=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?clientsecret=secret", + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz?X-Amz-Signature=abc123", + "https://registry.example/alpha.tgz?sv=2024-11-04&sig=secret", + "https://registry.example/alpha.tgz?mirror=ghp_EXAMPLEVALUE123456", + "https://registry.example/alpha.tgz?mirror=%67hp_EXAMPLEVALUE123456", + "https://registry.example/alpha.tgz?mirror=token%3Dsecret", + "https://registry.example/alpha.tgz?mirror=clientSecret%253Dsecret", + "https://registry.example/alpha.tgz?mirror=auth.token%3Dsecret", + "https://registry.example/alpha.tgz?mirror=artifact%3Fapikey%3Dsecret", + "https://registry.example/alpha.tgz?mirror=https%3A%2F%2Fcdn.example%2Fa.tgz%3Ftoken%3Dsecret", + "https://registry.example/alpha.tgz?mirror=https%253A%252F%252Fcdn.example%252Fa.tgz%253FclientSecret%253Dsecret", + "https://registry.example/alpha.tgz?mirror=https%3A%2F%2Fcdn.example%2Fa.tgz%3Fnext%3Dtoken%253Dsecret", + "https://registry.example/alpha.tgz?mirror=https%3A%2F%2Fcdn.example%2Fa.tgz%23token%3Dsecret", + "https://registry.example/alpha.tgz?mirror=https%3A%2F%2Fuser%3Asecret%40cdn.example%2Fa.tgz", + "https://registry.example/alpha.tgz?mirror=https%3A%2F%2Fcdn.example%2Fdownload%2Fgithub_pat_EXAMPLEVALUE123456%2Falpha.tgz", + "https://registry.example/alpha.tgz?mirror=git%2Bssh%3A%2F%2Fghp_secret%40github.com%2Facme%2Falpha.git", + "git+ssh://ghp_secret@github.com/acme/alpha.git#0123456789abcdef", + "git+ssh://user:secret@github.com/acme/alpha.git#0123456789abcdef", + "https://registry.example/alpha.tgz#token=secret", + "https://registry.example/alpha.tgz#artifact?token=secret", + "https://registry.example/alpha.tgz#https://user:secret@cdn.example/a.tgz", + "https://registry.example/alpha.tgz#https%3A%2F%2Fuser%3Asecret%40cdn.example%2Fa.tgz", + "git+ssh://git@github.com/acme/alpha.git#semver:^1.0.0?access_token=secret", + "https://registry.example/alpha.tgz#artifact%3Ftoken=secret", + "https://registry.example/alpha.tgz#artifact%253FclientSecret=secret", + "https://registry.example/alpha.tgz#artifact%253F%252574oken=secret", + "https://registry.example/alpha.tgz#ghp_EXAMPLEVALUE123456", + "https://registry.example/alpha.tgz#%67hp_EXAMPLEVALUE123456", + ])("rejects credential-bearing resolved artifact authority: %s", (resolved) => { + expect(() => buildDependencyLicenseInventory(lockWithResolved(resolved))).toThrow( + "node_modules/alpha: credential-free resolved required", + ); + }); + + it.each([ + "not a URI", + "HTTPS://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + "https://registry.npmjs.org:443/alpha/-/alpha-1.0.0.tgz", + "https://registry.example/alpha.tgz#artifact%ZZ", + ])("rejects non-canonical resolved artifact identity: %s", (resolved) => { + expect(() => buildDependencyLicenseInventory(lockWithResolved(resolved))).toThrow( + "node_modules/alpha: canonical resolved artifact URI required", + ); + }); + + it("preserves an ordinary immutable HTTPS artifact URL", () => { + const inventory = buildDependencyLicenseInventory( + lockWithResolved("https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz"), + ); + + expect(inventory.packages[0].resolved).toBe( + "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + ); + }); + + it("preserves a benign nested parameter assignment without credential authority", () => { + const resolved = "https://registry.example/alpha.tgz?mirror=channel%3Dstable"; + const inventory = buildDependencyLicenseInventory(lockWithResolved(resolved)); + + expect(inventory.packages[0].resolved).toBe(resolved); + }); + + it("preserves a recursively encoded benign nested assignment", () => { + const resolved = "https://registry.example/alpha.tgz?mirror=channel%253Dstable"; + const inventory = buildDependencyLicenseInventory(lockWithResolved(resolved)); + + expect(inventory.packages[0].resolved).toBe(resolved); + }); + + it("preserves a benign opaque nested query while inspecting its suffix authority", () => { + const resolved = "https://registry.example/alpha.tgz?mirror=artifact%3Fchannel%3Dstable"; + const inventory = buildDependencyLicenseInventory(lockWithResolved(resolved)); + + expect(inventory.packages[0].resolved).toBe(resolved); + }); + + it("fails closed when nested parameter inspection exceeds its bounded authority", () => { + let nestedValue = "stable"; + for (let depth = 0; depth < 10; depth += 1) { + nestedValue = `next=${encodeURIComponent(nestedValue)}`; + } + const resolved = `https://registry.example/alpha.tgz?mirror=${encodeURIComponent(nestedValue)}`; + + expect(() => buildDependencyLicenseInventory(lockWithResolved(resolved))).toThrow( + "node_modules/alpha: credential-free resolved required", + ); + }); + + it("preserves credential-like words that are only part of a nested artifact path", () => { + const resolved = + "https://registry.example/alpha.tgz?mirror=https%3A%2F%2Fcdn.example%2Ftoken%3Dpublic%2Fsecret%3Dmetadata"; + const inventory = buildDependencyLicenseInventory(lockWithResolved(resolved)); + + expect(inventory.packages[0].resolved).toBe(resolved); + }); + + it("preserves a benign nested artifact URL parameter without credential authority", () => { + const resolved = + "https://registry.example/alpha.tgz?mirror=https%3A%2F%2Fcdn.example%2Fa.tgz%3Fchannel%3Dstable"; + const inventory = buildDependencyLicenseInventory(lockWithResolved(resolved)); + + expect(inventory.packages[0].resolved).toBe(resolved); + }); + + it("preserves the conventional git username inside a nested SSH artifact URL", () => { + const resolved = + "https://registry.example/alpha.tgz?mirror=git%2Bssh%3A%2F%2Fgit%40github.com%2Facme%2Falpha.git%230123456789abcdef"; + const inventory = buildDependencyLicenseInventory(lockWithResolved(resolved)); + + expect(inventory.packages[0].resolved).toBe(resolved); + }); + + it("preserves a benign fragment-local query while exercising its nested parameter boundary", () => { + const inventory = buildDependencyLicenseInventory( + lockWithResolved("https://registry.example/alpha.tgz#artifact?channel=stable"), + ); + + expect(inventory.packages[0].resolved).toBe( + "https://registry.example/alpha.tgz#artifact?channel=stable", + ); + }); + + it("does not misclassify an encoded benign fragment as a credential", () => { + const inventory = buildDependencyLicenseInventory( + lockWithResolved("git+ssh://git@github.com/acme/alpha.git#semver:%5E1.0.0"), + ); + + expect(inventory.packages[0].resolved).toBe( + "git+ssh://git@github.com/acme/alpha.git#semver:%5E1.0.0", + ); + }); + + it("preserves an encoded literal percent in a benign fragment", () => { + const inventory = buildDependencyLicenseInventory( + lockWithResolved("git+ssh://git@github.com/acme/alpha.git#sha%25suffix"), + ); + + expect(inventory.packages[0].resolved).toBe( + "git+ssh://git@github.com/acme/alpha.git#sha%25suffix", + ); + }); + + it("does not misclassify the conventional git SSH username as a secret", () => { + const inventory = buildDependencyLicenseInventory( + lockWithResolved("git+ssh://git@github.com/acme/alpha.git#0123456789abcdef"), + ); + + expect(inventory.packages[0].resolved).toBe( + "git+ssh://git@github.com/acme/alpha.git#0123456789abcdef", + ); + }); +}); diff --git a/test/dependency-license-inventory-source-path.test.ts b/test/dependency-license-inventory-source-path.test.ts new file mode 100644 index 000000000..dd05878a0 --- /dev/null +++ b/test/dependency-license-inventory-source-path.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +const lockBytes = JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-alpha", + }, + }, +}); + +describe("dependency license inventory source-path authority", () => { + it.each([ + " package-lock.json", + "package-lock.json ", + "package-lock.json\nforged", + "package-lock.json\u202eforged", + "./package-lock.json", + "artifacts/../package-lock.json", + ])("rejects non-canonical source path %j", (sourcePath) => { + expect(() => buildDependencyLicenseInventory(lockBytes, { sourcePath })).toThrow( + "package-lock.json source path must be canonical", + ); + }); +}); diff --git a/test/dependency-license-inventory-special-output.test.ts b/test/dependency-license-inventory-special-output.test.ts new file mode 100644 index 000000000..00930853a --- /dev/null +++ b/test/dependency-license-inventory-special-output.test.ts @@ -0,0 +1,70 @@ +import { lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + while (temporaryRoots.length > 0) { + const root = temporaryRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } +}); + +function lockfileBytes() { + return `${JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { + name: "noema", + version: "0.1.0", + }, + "node_modules/alpha": { + version: "1.0.0", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: "sha512-ujzlhmfKmxKzwM3MTaV/mWKuynBlxDp9nAJzMv258LvPaQBChogP49jz/Y8D3f/XSF/ZTJ06OGGOoQaR2Nan+g==", + license: "MIT", + }, + }, + }, null, 2)}\n`; +} + +describe("dependency license inventory special-file output authority", () => { + it("refuses to unlink an existing Unix socket at the evidence output path", async () => { + if (process.platform === "win32") return; + + const root = mkdtempSync(join(tmpdir(), "noema-license-special-output-")); + temporaryRoots.push(root); + const lockPath = join(root, "package-lock.json"); + const outputPath = join(root, "dependency-licenses.sock"); + writeFileSync(lockPath, lockfileBytes(), "utf8"); + + const server = createServer(); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(outputPath, () => { + server.off("error", onError); + resolve(); + }); + }); + + try { + expect(lstatSync(outputPath).isSocket()).toBe(true); + expect(() => generateDependencyLicenseInventory({ lockPath, outputPath })).toThrow( + "dependency license inventory output must be a regular file", + ); + expect(lstatSync(outputPath).isSocket()).toBe(true); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/test/dependency-license-inventory-sri-digest-length.test.ts b/test/dependency-license-inventory-sri-digest-length.test.ts new file mode 100644 index 000000000..bfea40f75 --- /dev/null +++ b/test/dependency-license-inventory-sri-digest-length.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockfileWithIntegrity(integrity: string) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/alpha": { + version: "1.0.0", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity, + license: "MIT", + }, + }, + }); +} + +describe("dependency license inventory SRI digest authority", () => { + it("rejects algorithm labels whose decoded digest length does not match the algorithm", () => { + expect(() => + buildDependencyLicenseInventory(lockfileWithIntegrity("sha256-YQ==")), + ).toThrow("node_modules/alpha: supported SRI integrity required"); + + const validSha256 = `sha256-${Buffer.alloc(32, 0xa5).toString("base64")}`; + expect( + buildDependencyLicenseInventory(lockfileWithIntegrity(validSha256)).packages[0] + .integrity, + ).toBe(validSha256); + }); +}); diff --git a/test/dependency-license-inventory-weak-integrity.test.ts b/test/dependency-license-inventory-weak-integrity.test.ts new file mode 100644 index 000000000..8b57d03ac --- /dev/null +++ b/test/dependency-license-inventory-weak-integrity.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { buildDependencyLicenseInventory } from "../scripts/dependency-license-inventory.mjs"; + +function lockWithIntegrity(integrity: string) { + return JSON.stringify({ + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "noema", version: "0.1.0" }, + "node_modules/legacy-package": { + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/legacy-package/-/legacy-package-1.0.0.tgz", + integrity, + }, + }, + }); +} + +describe("dependency license inventory integrity authority", () => { + it("rejects SHA-1 SRI instead of retaining weak artifact integrity as buyer evidence", () => { + expect(() => + buildDependencyLicenseInventory(lockWithIntegrity("sha1-ZmFrZS1kaWdlc3Q=")), + ).toThrow( + "node_modules/legacy-package: supported SRI integrity required", + ); + }); + + it("continues to accept modern SRI algorithms with algorithm-sized digests", () => { + for (const integrity of [ + "sha256-bRYJ57z/Lx271kuqH6EypeeBrJIJ9HX7vy57S+Fedms=", + "sha384-laO4HXxnaL930+o4t6XS9YCpTAU/u++ngpdDUuMGLT3O5e0HHY4c+cQFkm4c77GB", + "sha512-32hOqBzNFYIMeVIm2Pas733Ve7IXiD5OsiUxK4GrvXDzJwWWEBDUndfiaJPApcjqauu1cTcf1Id6C8fPRh6kEg==", + ]) { + const inventory = buildDependencyLicenseInventory(lockWithIntegrity(integrity)); + expect(inventory.packages[0]?.integrity).toBe(integrity); + } + }); +}); diff --git a/test/dependency-license-inventory.test.ts b/test/dependency-license-inventory.test.ts new file mode 100644 index 000000000..b40ebcd21 --- /dev/null +++ b/test/dependency-license-inventory.test.ts @@ -0,0 +1,395 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + linkSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildDependencyLicenseInventory, + generateDependencyLicenseInventory, +} from "../scripts/dependency-license-inventory.mjs"; + +const temporaryRoots: string[] = []; +const MAXIMUM_LOCKFILE_BYTES = 4 * 1024 * 1024; + +function fixtureLock(packages: Record) { + return { + name: "noema", + version: "0.1.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { + name: "noema", + version: "0.1.0", + devDependencies: { alpha: "1.0.0" }, + }, + ...packages, + }, + }; +} + +function sri512(label: string) { + return `sha512-${createHash("sha512").update(label).digest("base64")}`; +} + +function packageRecord(overrides: Record = {}) { + return { + version: "1.0.0", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: sri512("alpha"), + license: "MIT", + dev: true, + ...overrides, + }; +} + +afterEach(() => { + while (temporaryRoots.length > 0) { + const root = temporaryRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } +}); + +describe("dependency license inventory", () => { + it("emits deterministic exact-lock identities sorted by package path", () => { + const lock = fixtureLock({ + "node_modules/zeta/node_modules/@scope/beta": packageRecord({ + version: "2.0.0", + resolved: "https://registry.npmjs.org/@scope/beta/-/beta-2.0.0.tgz", + integrity: sri512("beta"), + license: "Apache-2.0", + optional: true, + }), + "node_modules/alpha": packageRecord(), + }); + const lockBytes = `${JSON.stringify(lock, null, 2)}\n`; + + const inventory = buildDependencyLicenseInventory(lockBytes); + + expect(inventory).toEqual({ + schema_version: 1, + source: { + path: "package-lock.json", + sha256: createHash("sha256").update(lockBytes).digest("hex"), + lockfile_version: 3, + }, + packages: [ + { + package_path: "node_modules/alpha", + name: "alpha", + version: "1.0.0", + license: "MIT", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz", + integrity: sri512("alpha"), + dev: true, + optional: false, + }, + { + package_path: "node_modules/zeta/node_modules/@scope/beta", + name: "@scope/beta", + version: "2.0.0", + license: "Apache-2.0", + resolved: "https://registry.npmjs.org/@scope/beta/-/beta-2.0.0.tgz", + integrity: sri512("beta"), + dev: true, + optional: true, + }, + ], + }); + }); + + it("uses code-unit ordering rather than host locale collation", () => { + const lockBytes = JSON.stringify( + fixtureLock({ + "node_modules/alpha": packageRecord(), + "node_modules/Zeta": packageRecord({ + resolved: "https://registry.npmjs.org/Zeta/-/Zeta-1.0.0.tgz", + integrity: sri512("zeta"), + }), + }), + ); + + const inventory = buildDependencyLicenseInventory(lockBytes); + + expect(inventory.packages.map((entry) => entry.package_path)).toEqual([ + "node_modules/Zeta", + "node_modules/alpha", + ]); + }); + + it.each([ + ["license", { license: "" }], + ["version", { version: "" }], + ["resolved", { resolved: "" }], + ["integrity", { integrity: "" }], + ])("fails closed when a registry dependency lacks %s identity", (field, override) => { + const lockBytes = JSON.stringify( + fixtureLock({ "node_modules/alpha": packageRecord(override) }), + ); + + expect(() => buildDependencyLicenseInventory(lockBytes)).toThrow( + `node_modules/alpha: non-empty ${field} required`, + ); + }); + + it("fails closed on non-string identities rather than coercing package metadata", () => { + const lockBytes = JSON.stringify( + fixtureLock({ + "node_modules/alpha": packageRecord({ license: null }), + }), + ); + + expect(() => buildDependencyLicenseInventory(lockBytes)).toThrow( + "node_modules/alpha: non-empty license required", + ); + }); + + it("rejects malformed, duplicate-key, or unsupported lockfiles instead of inventing evidence", () => { + expect(() => buildDependencyLicenseInventory("not-json")).toThrow( + "package-lock.json must be valid JSON", + ); + expect(() => + buildDependencyLicenseInventory( + '{"lockfileVersion":3,"packages":{"node_modules/alpha":{"version":"1.0.0","version":"2.0.0","license":"MIT","resolved":"https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz","integrity":"sha512-alpha"}}}', + ), + ).toThrow("package-lock.json must not contain duplicate object keys"); + expect(() => buildDependencyLicenseInventory("null")).toThrow( + "package-lock.json object required", + ); + expect(() => + buildDependencyLicenseInventory( + JSON.stringify({ lockfileVersion: 2, packages: {} }), + ), + ).toThrow("package-lock.json lockfileVersion 3 required"); + expect(() => + buildDependencyLicenseInventory( + JSON.stringify({ lockfileVersion: 3, packages: [] }), + ), + ).toThrow("package-lock.json packages object required"); + }); + + it("rejects malformed package records and package paths", () => { + expect(() => + buildDependencyLicenseInventory( + JSON.stringify(fixtureLock({ "node_modules/alpha": null })), + ), + ).toThrow("node_modules/alpha: package object required"); + expect(() => + buildDependencyLicenseInventory( + JSON.stringify(fixtureLock({ alpha: packageRecord() })), + ), + ).toThrow("alpha: canonical node_modules package path required"); + expect(() => + buildDependencyLicenseInventory( + JSON.stringify(fixtureLock({ "prefixnode_modules/alpha": packageRecord() })), + ), + ).toThrow("prefixnode_modules/alpha: canonical node_modules package path required"); + expect(() => + buildDependencyLicenseInventory( + JSON.stringify(fixtureLock({ "node_modules/../alpha": packageRecord() })), + ), + ).toThrow("node_modules/../alpha: canonical package name required"); + expect(() => + buildDependencyLicenseInventory( + JSON.stringify(fixtureLock({ "node_modules/@scope": packageRecord() })), + ), + ).toThrow("node_modules/@scope: canonical package name required"); + expect(() => + buildDependencyLicenseInventory( + JSON.stringify(fixtureLock({ "node_modules/": packageRecord() })), + ), + ).toThrow("node_modules/: canonical package name required"); + }); + + it("rejects non-string lock bytes and empty source identities", () => { + expect(() => + buildDependencyLicenseInventory(Buffer.from("{}") as unknown as string), + ).toThrow("package-lock.json bytes must be a string"); + expect(() => + buildDependencyLicenseInventory( + JSON.stringify(fixtureLock({ "node_modules/alpha": packageRecord() })), + { sourcePath: "" }, + ), + ).toThrow("package-lock.json source path must be a non-empty string"); + }); + + it("validates every third-party entry in the repository lockfile", () => { + const lockBytes = readFileSync( + new URL("../package-lock.json", import.meta.url), + "utf8", + ); + + const inventory = buildDependencyLicenseInventory(lockBytes); + + expect(inventory.packages.length).toBeGreaterThan(0); + expect(inventory.packages.every((entry) => entry.license.length > 0)).toBe(true); + expect(inventory.source.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it("writes reproducible JSON bytes and binds custom input paths honestly", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-inventory-")); + temporaryRoots.push(root); + const lockPath = join(root, "custom-package-lock.json"); + const outputPath = join(root, "dependency-licenses.json"); + writeFileSync( + lockPath, + `${JSON.stringify( + fixtureLock({ "node_modules/alpha": packageRecord() }), + null, + 2, + )}\n`, + "utf8", + ); + + const first = generateDependencyLicenseInventory({ lockPath, outputPath }); + const firstBytes = readFileSync(outputPath, "utf8"); + const second = generateDependencyLicenseInventory({ lockPath, outputPath }); + const secondBytes = readFileSync(outputPath, "utf8"); + + expect(first).toEqual(second); + expect(first.source.path).toBe(lockPath); + expect(firstBytes).toBe(secondBytes); + expect(first.packages.map((entry) => entry.name)).toEqual(["alpha"]); + expect(firstBytes.endsWith("\n")).toBe(true); + }); + + it("refuses an oversized lockfile before parsing or authenticating its bytes", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-oversized-lock-")); + temporaryRoots.push(root); + const lockPath = join(root, "package-lock.json"); + const outputPath = join(root, "dependency-licenses.json"); + const validLockBytes = JSON.stringify( + fixtureLock({ "node_modules/alpha": packageRecord() }), + ); + writeFileSync( + lockPath, + `${validLockBytes}${" ".repeat(MAXIMUM_LOCKFILE_BYTES + 1)}`, + "utf8", + ); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow(`exceeds the ${MAXIMUM_LOCKFILE_BYTES}-byte ceiling`); + expect(existsSync(outputPath)).toBe(false); + }); + + it("fails closed on invalid UTF-8 lock bytes instead of authenticating replacement characters", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-invalid-utf8-")); + temporaryRoots.push(root); + const lockPath = join(root, "package-lock.json"); + const outputPath = join(root, "dependency-licenses.json"); + const validPrefix = Buffer.from( + '{"lockfileVersion":3,"packages":{"node_modules/alpha":{"version":"1.0.0","license":"MIT","resolved":"https://registry.npmjs.org/alpha/-/alpha-', + "utf8", + ); + const validSuffix = Buffer.from( + '.tgz","integrity":"sha512-alpha"}}}', + "utf8", + ); + writeFileSync(lockPath, Buffer.concat([validPrefix, Buffer.from([0xff]), validSuffix])); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow("package-lock.json must be valid UTF-8"); + expect(existsSync(outputPath)).toBe(false); + }); + + it("rejects a UTF-8 BOM instead of hashing normalized lockfile bytes", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-bom-")); + temporaryRoots.push(root); + const lockPath = join(root, "package-lock.json"); + const outputPath = join(root, "dependency-licenses.json"); + const lockBytes = Buffer.from( + JSON.stringify(fixtureLock({ "node_modules/alpha": packageRecord() })), + "utf8", + ); + writeFileSync( + lockPath, + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), lockBytes]), + ); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow("package-lock.json must be valid JSON"); + expect(existsSync(outputPath)).toBe(false); + }); + + it("refuses a symlinked lockfile instead of authenticating redirected bytes", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-lock-symlink-")); + temporaryRoots.push(root); + const targetPath = join(root, "alternate-package-lock.json"); + const lockPath = join(root, "package-lock.json"); + const outputPath = join(root, "dependency-licenses.json"); + writeFileSync( + targetPath, + `${JSON.stringify( + fixtureLock({ "node_modules/alpha": packageRecord() }), + null, + 2, + )}\n`, + "utf8", + ); + symlinkSync(targetPath, lockPath); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow(); + expect(existsSync(outputPath)).toBe(false); + }); + + it("refuses a symlinked evidence output instead of overwriting its target", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-symlink-")); + temporaryRoots.push(root); + const lockPath = join(root, "package-lock.json"); + const targetPath = join(root, "protected-target.json"); + const outputPath = join(root, "dependency-licenses.json"); + writeFileSync( + lockPath, + `${JSON.stringify( + fixtureLock({ "node_modules/alpha": packageRecord() }), + null, + 2, + )}\n`, + "utf8", + ); + writeFileSync(targetPath, "must remain unchanged\n", "utf8"); + symlinkSync(targetPath, outputPath); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow(); + expect(readFileSync(targetPath, "utf8")).toBe("must remain unchanged\n"); + }); + + it("refuses a hard-linked evidence output instead of truncating another file", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-hardlink-")); + temporaryRoots.push(root); + const lockPath = join(root, "package-lock.json"); + const targetPath = join(root, "protected-target.json"); + const outputPath = join(root, "dependency-licenses.json"); + writeFileSync( + lockPath, + `${JSON.stringify( + fixtureLock({ "node_modules/alpha": packageRecord() }), + null, + 2, + )}\n`, + "utf8", + ); + writeFileSync(targetPath, "must remain unchanged\n", "utf8"); + linkSync(targetPath, outputPath); + + expect(() => + generateDependencyLicenseInventory({ lockPath, outputPath }), + ).toThrow(); + expect(readFileSync(targetPath, "utf8")).toBe("must remain unchanged\n"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c775feaf3..cd7b1a4fa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "patch-validator/validate-patch.mjs", "patch-validator/runtime.mjs", "scripts/actions-runner-assignment-audit.mjs", + "scripts/dependency-license-inventory.mjs", "scripts/kpi-gate.mjs", "scripts/lockfile-change-control.mjs", "scripts/maintainer-app-readiness.mjs", @@ -43,4 +44,4 @@ export default defineConfig({ }, }, }, -}); +}); \ No newline at end of file