Skip to content

Commit dc2447f

Browse files
roblourensCopilot
andauthored
nodejs: enforce exact npm dependency policy (#2700)
* Enforce exact npm dependency policy Pin Node.js production dependencies at their existing resolved versions and require external npm releases to age seven full days before release packaging. Preserve exact requirements in packed packages and default future npm saves to exact versions. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 823a3ba3-43a5-4b6a-be71-625e7660994a * Validate resolved production dependency ages Walk the package-lock v3 production closure so optional and transitive package versions are subject to the seven-day npm publication policy while dev-only packages remain excluded. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 823a3ba3-43a5-4b6a-be71-625e7660994a --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 823a3ba3-43a5-4b6a-be71-625e7660994a
1 parent 0dd9d43 commit dc2447f

9 files changed

Lines changed: 433 additions & 11 deletions

‎nodejs/.npmrc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
omit-lockfile-registry-resolved=true
2+
save-exact=true

‎nodejs/package-lock.json‎

Lines changed: 10 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎nodejs/package.json‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"clean": "rimraf --glob dist *.tgz",
3838
"build": "tsx esbuild-copilotsdk-nodejs.ts",
3939
"acquire:runtime-packages": "tsx scripts/runtime-package-acquisition.ts",
40-
"pack:release": "tsx scripts/package-sdk.ts",
40+
"pack:release": "npm run verify:dependency-policy && tsx scripts/package-sdk.ts",
41+
"verify:dependency-policy": "tsx scripts/verify-dependency-policy.ts",
4142
"verify:release-packages": "tsx scripts/verify-release-packages.ts",
4243
"release:manifest": "tsx scripts/release-manifest.ts",
4344
"prepare:runtime": "tsx scripts/prepare-runtime.ts",
@@ -64,13 +65,14 @@
6465
"author": "GitHub",
6566
"license": "MIT",
6667
"dependencies": {
67-
"koffi": "^3.2.1",
68-
"vscode-jsonrpc": "^8.2.1",
69-
"zod": "^4.3.6"
68+
"koffi": "3.2.1",
69+
"vscode-jsonrpc": "8.2.1",
70+
"zod": "4.3.6"
7071
},
7172
"devDependencies": {
7273
"@platformatic/vfs": "^0.3.0",
7374
"@types/node": "^25.2.0",
75+
"@types/semver": "7.8.0",
7476
"@types/ws": "^8.18.1",
7577
"@typescript-eslint/eslint-plugin": "^8.54.0",
7678
"@typescript-eslint/parser": "^8.54.0",

‎nodejs/samples/package-lock.json‎

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import assert from "node:assert/strict";
2+
import { readFileSync } from "node:fs";
3+
import * as semver from "semver";
4+
5+
const NPM_REGISTRY_URL = "https://registry.npmjs.org";
6+
7+
export const MINIMUM_DEPENDENCY_AGE_MS = 7 * 24 * 60 * 60 * 1000;
8+
9+
export interface ProductionDependencyManifest {
10+
dependencies?: Record<string, string>;
11+
optionalDependencies?: Record<string, string>;
12+
}
13+
14+
export interface ProductionDependency {
15+
name: string;
16+
version: string;
17+
}
18+
19+
export interface RegistryResponse {
20+
json(): Promise<unknown>;
21+
ok: boolean;
22+
status: number;
23+
statusText: string;
24+
}
25+
26+
export type RegistryRequest = (url: string) => Promise<RegistryResponse>;
27+
28+
function isRecord(value: unknown): value is Record<string, unknown> {
29+
return typeof value === "object" && value !== null && !Array.isArray(value);
30+
}
31+
32+
function dependencyKey(dependency: ProductionDependency): string {
33+
return `${dependency.name}@${dependency.version}`;
34+
}
35+
36+
function readDependencySection(
37+
manifest: Record<string, unknown>,
38+
section: "dependencies" | "optionalDependencies"
39+
): Record<string, string> | undefined {
40+
const value = manifest[section];
41+
if (value === undefined) {
42+
return undefined;
43+
}
44+
assert(isRecord(value), `${section} must be a JSON object`);
45+
const dependencies: Record<string, string> = {};
46+
for (const [name, version] of Object.entries(value)) {
47+
assert(typeof version === "string", `${section}.${name} must be a string`);
48+
dependencies[name] = version;
49+
}
50+
return dependencies;
51+
}
52+
53+
export function readProductionDependencyManifest(path: string): ProductionDependencyManifest {
54+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
55+
assert(isRecord(parsed), "package.json must contain a JSON object");
56+
return {
57+
dependencies: readDependencySection(parsed, "dependencies"),
58+
optionalDependencies: readDependencySection(parsed, "optionalDependencies"),
59+
};
60+
}
61+
62+
export function assertExactProductionDependencies(
63+
manifest: ProductionDependencyManifest
64+
): ProductionDependency[] {
65+
const dependencies: ProductionDependency[] = [];
66+
for (const section of ["dependencies", "optionalDependencies"] as const) {
67+
for (const [name, version] of Object.entries(manifest[section] ?? {})) {
68+
assert.equal(
69+
semver.valid(version),
70+
version,
71+
`${section}.${name} must use an exact SemVer version; found '${version}'`
72+
);
73+
dependencies.push({ name, version });
74+
}
75+
}
76+
return dependencies;
77+
}
78+
79+
function packageNameFromLockfilePath(path: string): string | undefined {
80+
return /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/.exec(path)?.[1];
81+
}
82+
83+
export function parseResolvedProductionDependencies(lockfile: unknown): ProductionDependency[] {
84+
assert(isRecord(lockfile), "package-lock.json must contain a JSON object");
85+
assert.equal(lockfile.lockfileVersion, 3, "package-lock.json must use lockfileVersion 3");
86+
assert(isRecord(lockfile.packages), "package-lock.json must contain a packages object");
87+
88+
const dependencies = new Map<string, ProductionDependency>();
89+
for (const [path, value] of Object.entries(lockfile.packages)) {
90+
if (path === "") {
91+
continue;
92+
}
93+
assert(isRecord(value), `Package-lock entry '${path}' must be a JSON object`);
94+
// npm marks dev=true only when a package is strictly dev-only. Optional and
95+
// devOptional packages reachable from production dependencies remain included.
96+
if (value.dev === true || value.link === true || value.inBundle === true) {
97+
continue;
98+
}
99+
const name = packageNameFromLockfilePath(path);
100+
assert(name, `Unsupported production package-lock path '${path}'`);
101+
const version = value.version;
102+
assert(
103+
typeof version === "string" && semver.valid(version) === version,
104+
`Resolved production dependency ${name} must use an exact SemVer version`
105+
);
106+
dependencies.set(`${name}@${version}`, { name, version });
107+
}
108+
return [...dependencies.values()];
109+
}
110+
111+
export function readResolvedProductionDependencies(path: string): ProductionDependency[] {
112+
return parseResolvedProductionDependencies(JSON.parse(readFileSync(path, "utf8")) as unknown);
113+
}
114+
115+
export function requiresPublicationCooldown(dependencyName: string): boolean {
116+
return !dependencyName.startsWith("@github/");
117+
}
118+
119+
export async function loadNpmPublicationTimes(
120+
dependencies: readonly ProductionDependency[],
121+
request: RegistryRequest = (url) => fetch(url)
122+
): Promise<Map<string, string>> {
123+
const externalDependencies = dependencies.filter((dependency) =>
124+
requiresPublicationCooldown(dependency.name)
125+
);
126+
const packageNames = [...new Set(externalDependencies.map((dependency) => dependency.name))];
127+
const metadata = new Map<string, Record<string, unknown>>();
128+
129+
await Promise.all(
130+
packageNames.map(async (packageName) => {
131+
const response = await request(
132+
`${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}`
133+
);
134+
assert(
135+
response.ok,
136+
`npm metadata request for ${packageName} failed: ${response.status} ${response.statusText}`
137+
);
138+
const body = await response.json();
139+
assert(isRecord(body), `npm metadata for ${packageName} must be a JSON object`);
140+
const times = body.time;
141+
assert(isRecord(times), `npm metadata for ${packageName} is missing publication times`);
142+
metadata.set(packageName, times);
143+
})
144+
);
145+
146+
return new Map(
147+
externalDependencies.map((dependency) => {
148+
const publishedAt = metadata.get(dependency.name)?.[dependency.version];
149+
assert(
150+
typeof publishedAt === "string",
151+
`npm metadata is missing a publication time for ${dependencyKey(dependency)}`
152+
);
153+
return [dependencyKey(dependency), publishedAt];
154+
})
155+
);
156+
}
157+
158+
export function assertMinimumPublicationAge(
159+
dependencies: readonly ProductionDependency[],
160+
publicationTimes: ReadonlyMap<string, string>,
161+
now = Date.now()
162+
): void {
163+
assert(Number.isFinite(now), "Dependency policy verification time must be finite");
164+
165+
for (const dependency of dependencies) {
166+
if (!requiresPublicationCooldown(dependency.name)) {
167+
continue;
168+
}
169+
const key = dependencyKey(dependency);
170+
const publishedAt = publicationTimes.get(key);
171+
assert(publishedAt, `Missing npm publication time for ${key}`);
172+
const publishedAtMs = Date.parse(publishedAt);
173+
assert(Number.isFinite(publishedAtMs), `Invalid npm publication time for ${key}`);
174+
const eligibleAtMs = publishedAtMs + MINIMUM_DEPENDENCY_AGE_MS;
175+
assert(
176+
now >= eligibleAtMs,
177+
`${key} was published at ${publishedAt} and is not eligible until ${new Date(
178+
eligibleAtMs
179+
).toISOString()}`
180+
);
181+
}
182+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { dirname, join } from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
import {
4+
assertExactProductionDependencies,
5+
assertMinimumPublicationAge,
6+
loadNpmPublicationTimes,
7+
readProductionDependencyManifest,
8+
readResolvedProductionDependencies,
9+
requiresPublicationCooldown,
10+
} from "./dependency-policy.js";
11+
12+
async function main(): Promise<void> {
13+
const nodeRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
14+
const manifest = readProductionDependencyManifest(join(nodeRoot, "package.json"));
15+
const directDependencies = assertExactProductionDependencies(manifest);
16+
const resolvedDependencies = readResolvedProductionDependencies(
17+
join(nodeRoot, "package-lock.json")
18+
);
19+
const publicationTimes = await loadNpmPublicationTimes(resolvedDependencies);
20+
assertMinimumPublicationAge(resolvedDependencies, publicationTimes);
21+
const externalCount = resolvedDependencies.filter((dependency) =>
22+
requiresPublicationCooldown(dependency.name)
23+
).length;
24+
console.log(
25+
`Verified ${directDependencies.length} exact direct production dependencies; ${externalCount} resolved production package versions meet the seven-day npm publication age requirement.`
26+
);
27+
}
28+
29+
main().catch((error) => {
30+
console.error(`::error::${error instanceof Error ? error.message : String(error)}`);
31+
process.exitCode = 1;
32+
});

‎nodejs/scripts/verify-release-packages.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { fileURLToPath } from "node:url";
66
import { globSync } from "glob";
77
import { t as listTar, x as extractTar } from "tar";
88
import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js";
9+
import { assertExactProductionDependencies } from "./dependency-policy.js";
910

1011
interface PackedPackage {
1112
manifest: {
13+
dependencies?: Record<string, string>;
1214
name: string;
15+
optionalDependencies?: Record<string, string>;
1316
version: string;
1417
repository?: string | { type?: string; url?: string };
15-
optionalDependencies?: Record<string, string>;
1618
};
1719
entries: Set<string>;
1820
}
@@ -27,6 +29,7 @@ assert(
2729
: sourceManifest.repository?.url?.trim(),
2830
"Main package is missing repository metadata"
2931
);
32+
assertExactProductionDependencies(sourceManifest);
3033
const expectedRuntimePackages = Object.fromEntries(
3134
RUNTIME_PLATFORMS.map((platform) => [getRuntimePackageName(platform), sourceManifest.version])
3235
);
@@ -77,11 +80,17 @@ assert.deepEqual(
7780

7881
const mainPackage = packages.get(sourceManifest.name);
7982
assert(mainPackage, `Missing ${sourceManifest.name} tarball`);
83+
assert.deepEqual(
84+
mainPackage.manifest.dependencies,
85+
sourceManifest.dependencies,
86+
"Main package dependencies do not match the source manifest"
87+
);
8088
assert.deepEqual(
8189
mainPackage.manifest.optionalDependencies,
8290
expectedRuntimePackages,
8391
"Main package optional dependencies do not match the platform packages"
8492
);
93+
assertExactProductionDependencies(mainPackage.manifest);
8594
assert(mainPackage.entries.has("package/dist/index.js"), "Main package is missing dist/index.js");
8695
assert(
8796
mainPackage.entries.has("package/dist/cjs/index.js"),

0 commit comments

Comments
 (0)