Skip to content

Commit bf69ffa

Browse files
committed
fix(deps): mirror ws/qs security overrides in ui via new override-parity rule
New package-override-parity lint-meta rule: an app's overrides must be reflected in its own bun.lock and mirrored by sibling apps that resolve the same package. Surfaced three real instances: ui missing ws@8.21.0 (GHSA-58qx-3vcg-4xpx) and qs@6.15.2 (GHSA-q8mj-m7cp-5q26) mirrors of the docs pins, and docs' bun.lock resolving @types/react@19.2.14 despite its 19.2.15 override. Also regenerates the docs lint-meta catalog for this rule and github-actions-timeout-required. Audit: F002
1 parent 7b62f8c commit bf69ffa

19 files changed

Lines changed: 354 additions & 6 deletions

File tree

apps/api/scripts/lint-meta/RULES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi
1616
| ----------------------------------- | ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
1717
| `package-json-exact-deps` | supply-chain | no | dependencies and devDependencies must use exact versions (no ranges). |
1818
| `no-overlapping-libs` | supply-chain | no | package.json must not list forbidden overlapping library pairs. |
19+
| `package-override-parity` | supply-chain | no | package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package. |
1920
| `shared-tool-version-parity` | supply-chain | no | Shared dev tooling (ESLint, TypeScript, Prettier, knip, …) must be pinned to the same version in every app that declares it. |
2021
| `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. |
2122
| `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). |

apps/api/scripts/lint-meta/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { META_RULES } from "./registry";
2020
import { printRuleCatalog, runMetaRules, runMetaRulesAsync } from "./runner";
2121
import { checkDependencyPairs } from "./rules/supply-chain/no-overlapping-libs";
2222
import { checkExactDependencyVersions } from "./rules/supply-chain/package-json-exact-deps";
23+
import { checkPackageOverrideParity } from "./rules/supply-chain/package-override-parity";
2324
import { checkSharedToolVersionParity } from "./rules/supply-chain/shared-tool-version-parity";
2425
import { checkEslintConfigNoWarn } from "./rules/config/eslint-config-no-warn";
2526
import { checkEnvSchemaDrift } from "./rules/env/env-cascade-drift";
@@ -99,6 +100,7 @@ export {
99100
checkLogicFilesHaveTests,
100101
checkNoDirectProcessEnv,
101102
checkNoRawRoleLiterals,
103+
checkPackageOverrideParity,
102104
checkPrePushParity,
103105
checkRouteFilesHaveTests,
104106
checkSharedToolVersionParity,

apps/api/scripts/lint-meta/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { forbiddenTextRule } from "./rules/source-text/forbidden-text";
1111
import { noRawRoleLiteralsRule } from "./rules/source-text/no-raw-role-literals";
1212
import { noOverlappingLibsRule } from "./rules/supply-chain/no-overlapping-libs";
1313
import { packageJsonExactDepsRule } from "./rules/supply-chain/package-json-exact-deps";
14+
import { packageOverrideParityRule } from "./rules/supply-chain/package-override-parity";
1415
import { sharedToolVersionParityRule } from "./rules/supply-chain/shared-tool-version-parity";
1516
import { logicFilesRequireTestSiblingRule } from "./rules/testing/logic-files-require-test-sibling";
1617
import { routesRequireTestSiblingRule } from "./rules/testing/routes-require-test-sibling";
@@ -21,6 +22,7 @@ import type { IMetaRule } from "./types";
2122
export const META_RULES: readonly IMetaRule[] = [
2223
packageJsonExactDepsRule,
2324
noOverlappingLibsRule,
25+
packageOverrideParityRule,
2426
sharedToolVersionParityRule,
2527
githubActionsPermissionsRule,
2628
githubActionsTimeoutRequiredRule,
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { readFileSync, readdirSync, statSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
import type { IMetaRule, IViolation } from "../../types";
5+
6+
/*
7+
* Security/parity `overrides` in one app's package.json must hold across the
8+
* monorepo: a sibling app that resolves the same package (per its bun.lock)
9+
* either mirrors the override or has consciously pinned the same version.
10+
* Two failure modes are caught:
11+
*
12+
* 1. Stale override — an app's own bun.lock resolves a different version
13+
* than its declared override (the override never took effect; run
14+
* `bun install`).
15+
* 2. Missing mirror — a sibling resolves the package at a different
16+
* version than the override and declares no override of its own
17+
* (e.g. a GHSA patch pinned in one app but not the others).
18+
*/
19+
20+
interface IAppOverrides {
21+
readonly app: string;
22+
readonly file: string;
23+
readonly overrides: Record<string, string>;
24+
readonly lockfileText: string | null;
25+
}
26+
27+
function toStringRecord(value: unknown): Record<string, string> {
28+
if (typeof value !== "object" || value === null) {
29+
return {};
30+
}
31+
32+
const out: Record<string, string> = {};
33+
34+
for (const [k, v] of Object.entries(value)) {
35+
if (typeof v === "string") {
36+
out[k] = v;
37+
}
38+
}
39+
40+
return out;
41+
}
42+
43+
function readApps(appsDir: string): IAppOverrides[] {
44+
const out: IAppOverrides[] = [];
45+
let entries: string[];
46+
47+
try {
48+
entries = readdirSync(appsDir);
49+
} catch {
50+
return out;
51+
}
52+
53+
for (const entry of entries) {
54+
const dir = join(appsDir, entry);
55+
56+
let isDir: boolean;
57+
58+
try {
59+
isDir = statSync(dir).isDirectory();
60+
} catch {
61+
continue;
62+
}
63+
64+
if (!isDir) {
65+
continue;
66+
}
67+
68+
const file = join(dir, "package.json");
69+
let parsed: unknown;
70+
71+
try {
72+
parsed = JSON.parse(readFileSync(file, "utf8"));
73+
} catch {
74+
continue;
75+
}
76+
77+
if (typeof parsed !== "object" || parsed === null) {
78+
continue;
79+
}
80+
81+
let overridesValue: unknown;
82+
83+
for (const [k, v] of Object.entries(parsed)) {
84+
if (k === "overrides") {
85+
overridesValue = v;
86+
}
87+
}
88+
89+
let lockfileText: string | null;
90+
91+
try {
92+
lockfileText = readFileSync(join(dir, "bun.lock"), "utf8");
93+
} catch {
94+
lockfileText = null;
95+
}
96+
97+
out.push({
98+
app: entry,
99+
file,
100+
overrides: toStringRecord(overridesValue),
101+
lockfileText,
102+
});
103+
}
104+
105+
return out;
106+
}
107+
108+
function escapeRegExp(text: string): string {
109+
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
110+
}
111+
112+
/** Versions the lockfile resolves for `name` (entries look like "name@1.2.3"). */
113+
function resolvedVersions(lockfileText: string, name: string): string[] {
114+
const regex = new RegExp(`"${escapeRegExp(name)}@(\\d[^"]*)"`, "gu");
115+
const versions = new Set<string>();
116+
let match: RegExpExecArray | null = regex.exec(lockfileText);
117+
118+
while (match !== null) {
119+
const version = match[1];
120+
121+
if (version !== undefined) {
122+
versions.add(version);
123+
}
124+
125+
match = regex.exec(lockfileText);
126+
}
127+
128+
return [...versions];
129+
}
130+
131+
type Reporter = (file: string, key: string, message: string) => void;
132+
133+
function checkStaleOverrides(app: IAppOverrides, report: Reporter): void {
134+
if (app.lockfileText === null) {
135+
return;
136+
}
137+
138+
for (const [name, version] of Object.entries(app.overrides)) {
139+
const resolved = resolvedVersions(app.lockfileText, name);
140+
141+
if (resolved.length > 0 && resolved.join(",") !== version) {
142+
report(
143+
app.file,
144+
`stale:${name}`,
145+
`Override ${name}@${version} is not what bun.lock resolves (${resolved.join(", ")}) — run \`bun install\` to apply it.`
146+
);
147+
}
148+
}
149+
}
150+
151+
function checkSiblingMirror(
152+
owner: IAppOverrides,
153+
sibling: IAppOverrides,
154+
name: string,
155+
version: string,
156+
report: Reporter
157+
): void {
158+
if (sibling.lockfileText === null) {
159+
return;
160+
}
161+
162+
const siblingOverride = sibling.overrides[name];
163+
164+
if (siblingOverride !== undefined) {
165+
if (siblingOverride !== version) {
166+
report(
167+
sibling.file,
168+
`drift:${name}`,
169+
`Override ${name}@${siblingOverride} drifts from ${owner.app}'s ${name}@${version} — align the pins or document why they differ.`
170+
);
171+
}
172+
173+
return;
174+
}
175+
176+
const resolved = resolvedVersions(sibling.lockfileText, name);
177+
178+
if (resolved.length > 0 && resolved.join(",") !== version) {
179+
report(
180+
sibling.file,
181+
`missing:${name}`,
182+
`${owner.app} overrides ${name}@${version} but this app resolves ${resolved.join(", ")} with no override — mirror the pin (it usually exists for a security advisory).`
183+
);
184+
}
185+
}
186+
187+
export function checkPackageOverrideParity(appsDir: string): IViolation[] {
188+
const violations: IViolation[] = [];
189+
const reported = new Set<string>();
190+
const apps = readApps(appsDir);
191+
192+
const report: Reporter = (file, key, message) => {
193+
if (reported.has(`${file}:${key}`)) {
194+
return;
195+
}
196+
197+
reported.add(`${file}:${key}`);
198+
violations.push({ file, rule: "package-override-parity", message });
199+
};
200+
201+
for (const app of apps) {
202+
checkStaleOverrides(app, report);
203+
}
204+
205+
for (const owner of apps) {
206+
for (const [name, version] of Object.entries(owner.overrides)) {
207+
for (const sibling of apps) {
208+
if (sibling.app !== owner.app) {
209+
checkSiblingMirror(owner, sibling, name, version, report);
210+
}
211+
}
212+
}
213+
}
214+
215+
return violations;
216+
}
217+
218+
/** Package overrides must be applied and mirrored across sibling apps. */
219+
export const packageOverrideParityRule: IMetaRule = {
220+
id: "package-override-parity",
221+
category: "supply-chain",
222+
description:
223+
"package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package.",
224+
run({ root }) {
225+
return checkPackageOverrideParity(join(root, ".."));
226+
},
227+
};

apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "fixture-app-a",
3+
"overrides": {
4+
"ws": "8.21.0"
5+
}
6+
}

apps/api/tests/lint-meta/fixtures/override-parity-clean/app-b/bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "fixture-app-b"
3+
}

apps/api/tests/lint-meta/fixtures/override-parity-drift/app-a/bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "fixture-app-a",
3+
"overrides": {
4+
"ws": "8.21.0"
5+
}
6+
}

0 commit comments

Comments
 (0)