@cosmicdrift/kumiko-guards 0.356.0 → 0.358.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/_lib/bun-lock-packages.ts +5 -5
  3. package/src/_lib/guard-kit.ts +180 -131
  4. package/src/_lib/qn.ts +9 -9
  5. package/src/_lib/roots.ts +4 -3
  6. package/src/_lib/security-baseline.ts +193 -116
  7. package/src/_lib/sql-inventory.ts +34 -32
  8. package/src/changes.json +6 -0
  9. package/src/check-as-casts.ts +196 -180
  10. package/src/check-complexity.ts +68 -69
  11. package/src/check-predicates.ts +93 -67
  12. package/src/check-secret-literals.ts +49 -38
  13. package/src/check-single-runtime-instance.ts +1 -1
  14. package/src/cli.ts +30 -17
  15. package/src/guard-access-denied-test.ts +49 -23
  16. package/src/guard-app-feature-structure.ts +92 -73
  17. package/src/guard-broker-subscribe.ts +17 -17
  18. package/src/guard-comment-lang.ts +126 -54
  19. package/src/guard-direct-entity-writes.ts +58 -45
  20. package/src/guard-escape-hatch-declared.ts +26 -23
  21. package/src/guard-html-escape.ts +112 -86
  22. package/src/guard-i18n-keys.ts +39 -36
  23. package/src/guard-i18n-locale-mount.ts +47 -40
  24. package/src/guard-i18n-ui-strings.ts +108 -92
  25. package/src/guard-lib-test-coverage.ts +37 -37
  26. package/src/guard-loadall-events.ts +17 -17
  27. package/src/guard-no-framed-extension-sections.ts +4 -4
  28. package/src/guard-no-temporal-polyfill-import.ts +19 -1
  29. package/src/guard-pii-annotations.ts +16 -16
  30. package/src/guard-primitives-discipline.ts +61 -65
  31. package/src/guard-screen-conventions.ts +2 -0
  32. package/src/guard-table-ddl.ts +56 -62
  33. package/src/guard-tenant-escalation.ts +42 -34
  34. package/src/guard-test-stack-drift.ts +6 -6
  35. package/src/guard-test-timeouts.ts +5 -4
  36. package/src/guard-text-field-stance.ts +22 -22
  37. package/src/guard-thin-wrappers.ts +42 -45
  38. package/src/guard-write-handler-qns.ts +29 -29
  39. package/src/runtime-isolation-classify.ts +4 -3
@@ -56,22 +56,42 @@ function isFindingsShape(value: unknown): value is SecurityBaseline["findings"]
56
56
  });
57
57
  }
58
58
 
59
+ type BaselineCore = Pick<SecurityBaseline, "format" | "repo" | "generated" | "total" | "findings">;
60
+
61
+ function hasBaselineCore(raw: object): raw is BaselineCore {
62
+ return (
63
+ "format" in raw &&
64
+ raw.format === SECURITY_BASELINE_FORMAT &&
65
+ "repo" in raw &&
66
+ typeof raw.repo === "string" &&
67
+ "generated" in raw &&
68
+ typeof raw.generated === "string" &&
69
+ "total" in raw &&
70
+ typeof raw.total === "number" &&
71
+ "findings" in raw &&
72
+ isFindingsShape(raw.findings)
73
+ );
74
+ }
75
+
76
+ // {} is allowed (guard has no current findings); any entry means it isn't done migrating yet.
77
+ function isValidHardFail(
78
+ hardFail: unknown,
79
+ findings: SecurityBaseline["findings"],
80
+ ): hardFail is readonly string[] {
81
+ if (!isStringArrayNoDuplicates(hardFail)) return false;
82
+ return hardFail.every((guardName) => {
83
+ const perFile = findings[guardName];
84
+ return !(perFile && Object.keys(perFile).length > 0);
85
+ });
86
+ }
87
+
59
88
  export function parseSecurityBaseline(raw: unknown): SecurityBaseline | undefined {
60
89
  if (typeof raw !== "object" || raw === null) return undefined;
61
- if (!("format" in raw) || raw.format !== SECURITY_BASELINE_FORMAT) return undefined;
62
- if (!("repo" in raw) || typeof raw.repo !== "string") return undefined;
63
- if (!("generated" in raw) || typeof raw.generated !== "string") return undefined;
64
- if (!("total" in raw) || typeof raw.total !== "number") return undefined;
65
- if (!("findings" in raw) || !isFindingsShape(raw.findings)) return undefined;
90
+ if (!hasBaselineCore(raw)) return undefined;
66
91
 
67
92
  let hardFail: readonly string[] | undefined;
68
93
  if ("hardFail" in raw && raw.hardFail !== undefined) {
69
- if (!isStringArrayNoDuplicates(raw.hardFail)) return undefined;
70
- for (const guardName of raw.hardFail) {
71
- const perFile = raw.findings[guardName];
72
- // {} is allowed (guard has no current findings); any entry means it isn't done migrating yet.
73
- if (perFile && Object.keys(perFile).length > 0) return undefined;
74
- }
94
+ if (!isValidHardFail(raw.hardFail, raw.findings)) return undefined;
75
95
  hardFail = raw.hardFail;
76
96
  }
77
97
 
@@ -107,7 +127,7 @@ export function loadSecurityBaseline(repo: string, repoDir: string): SecurityBas
107
127
  }
108
128
  const parsed = parseSecurityBaseline(raw);
109
129
  if (!parsed) {
110
- return { kind: "invalid", file, reason: "unexpected baseline format (format/repo/findings)" };
130
+ return { kind: "invalid", file, reason: "unexpected_baseline_shape" };
111
131
  }
112
132
  if (parsed.repo !== repo) {
113
133
  return {
@@ -133,36 +153,36 @@ export function locateFinding(
133
153
  return { repo: root.name, relPath: relative(root.absPath, abs).split(sep).join("/") };
134
154
  }
135
155
 
136
- export function applySecurityBaseline(args: {
137
- readonly guardName: string;
138
- readonly violations: readonly GuardViolation[];
139
- readonly roots: readonly { readonly name: string; readonly absPath: string }[];
140
- readonly cwd: string;
141
- readonly load: (repo: string) => SecurityBaselineLoad;
142
- /** Also fail on baseline headroom (current < baseline) across every root, not just repos with current findings. */
143
- readonly strict?: boolean;
144
- }): { readonly blocking: GuardViolation[]; readonly frozen: number; readonly reduced: number } {
145
- const { guardName, violations, roots, cwd, load, strict } = args;
156
+ type RepoRootList = readonly { readonly name: string; readonly absPath: string }[];
146
157
 
147
- type Located = {
148
- readonly relPath: string;
149
- readonly index: number;
150
- readonly violation: GuardViolation;
151
- };
152
- const byRepo = new Map<string, Map<string, Located[]>>();
153
- // (sortKey, violation) so the final blocking list preserves the original
154
- // violation order even after synthetic baseline-file entries are spliced in.
155
- const blockingEntries: Array<[number, GuardViolation]> = [];
158
+ type LocatedViolation = {
159
+ readonly relPath: string;
160
+ readonly index: number;
161
+ readonly violation: GuardViolation;
162
+ };
163
+
164
+ type FindingsByRepo = Map<string, Map<string, LocatedViolation[]>>;
165
+
166
+ // (sortKey, violation) so the final blocking list preserves the original
167
+ // violation order even after synthetic baseline-file entries are spliced in.
168
+ type BlockingEntry = [number, GuardViolation];
156
169
 
157
- violations.forEach((violation, index) => {
170
+ function groupFindingsByRepo(
171
+ violations: readonly GuardViolation[],
172
+ roots: RepoRootList,
173
+ cwd: string,
174
+ blockingEntries: BlockingEntry[],
175
+ ): FindingsByRepo {
176
+ const byRepo: FindingsByRepo = new Map();
177
+ for (const [index, violation] of violations.entries()) {
158
178
  if (violation.neverFrozen) {
159
179
  blockingEntries.push([index, violation]);
160
- return;
180
+ continue;
161
181
  }
162
182
  const located = locateFinding(violation.file, roots, cwd);
163
183
  if (!located) {
164
184
  blockingEntries.push([index, violation]);
165
- return;
185
+ continue;
166
186
  }
167
187
  let perRepo = byRepo.get(located.repo);
168
188
  if (!perRepo) {
@@ -172,110 +192,167 @@ export function applySecurityBaseline(args: {
172
192
  const bucket = perRepo.get(located.relPath) ?? [];
173
193
  bucket.push({ relPath: located.relPath, index, violation });
174
194
  perRepo.set(located.relPath, bucket);
175
- });
195
+ }
196
+ return byRepo;
197
+ }
176
198
 
177
- const loadCache = new Map<string, SecurityBaselineLoad>();
178
- const loadOnce = (repo: string): SecurityBaselineLoad => {
179
- const cached = loadCache.get(repo);
180
- if (cached) return cached;
181
- const result = load(repo);
182
- loadCache.set(repo, result);
183
- return result;
199
+ function invalidBaselineViolation(
200
+ baseline: Extract<SecurityBaselineLoad, { kind: "invalid" }>,
201
+ ): GuardViolation {
202
+ return {
203
+ file: baseline.file,
204
+ line: 1,
205
+ message: `Security baseline unreadable or invalid: ${baseline.reason}. Fix the file; a broken baseline must not release any findings.`,
184
206
  };
207
+ }
185
208
 
186
- let frozen = 0;
187
- let reduced = 0;
209
+ function countsPerPath(
210
+ byPath: ReadonlyMap<string, readonly LocatedViolation[]>,
211
+ ): Record<string, number> {
212
+ const current: Record<string, number> = {};
213
+ for (const [relPath, entries] of byPath) current[relPath] = entries.length;
214
+ return current;
215
+ }
188
216
 
189
- for (const [repo, byPath] of byRepo) {
190
- const baseline = loadOnce(repo);
191
- const allEntries = [...byPath.values()].flat();
192
- if (baseline.kind === "invalid") {
193
- const minIndex = Math.min(...allEntries.map((e) => e.index));
217
+ function applyRepoBaseline(args: {
218
+ readonly guardName: string;
219
+ readonly repo: string;
220
+ readonly byPath: ReadonlyMap<string, readonly LocatedViolation[]>;
221
+ readonly baseline: SecurityBaselineLoad;
222
+ readonly blockingEntries: BlockingEntry[];
223
+ }): { readonly frozen: number; readonly reduced: number } {
224
+ const { guardName, repo, byPath, baseline, blockingEntries } = args;
225
+ const allEntries = [...byPath.values()].flat();
226
+ if (baseline.kind === "invalid") {
227
+ const minIndex = Math.min(...allEntries.map((e) => e.index));
228
+ blockingEntries.push([minIndex - 0.5, invalidBaselineViolation(baseline)]);
229
+ for (const e of allEntries) blockingEntries.push([e.index, e.violation]);
230
+ return { frozen: 0, reduced: 0 };
231
+ }
232
+ if (baseline.hardFail.includes(guardName)) {
233
+ for (const e of allEntries) {
194
234
  blockingEntries.push([
195
- minIndex - 0.5,
235
+ e.index,
196
236
  {
197
- file: baseline.file,
198
- line: 1,
199
- message: `Security baseline unreadable or invalid: ${baseline.reason}. Fix the file; a broken baseline must not release any findings.`,
237
+ ...e.violation,
238
+ message: `${e.violation.message} (security baseline ${repo}: ${guardName} finished migrating — no baseline tolerance)`,
200
239
  },
201
240
  ]);
202
- for (const e of allEntries) blockingEntries.push([e.index, e.violation]);
203
- continue;
204
241
  }
205
- if (baseline.hardFail.includes(guardName)) {
206
- for (const e of allEntries) {
242
+ return { frozen: 0, reduced: 0 };
243
+ }
244
+ const baselineForGuard = baseline.findings[guardName] ?? {};
245
+ const { regressions, reduced } = compareToBaseline(countsPerPath(byPath), baselineForGuard);
246
+ const regressionByPath = new Map(regressions.map((r) => [r.file, r]));
247
+ let frozen = 0;
248
+ for (const [relPath, entries] of byPath) {
249
+ const regression = regressionByPath.get(relPath);
250
+ if (regression) {
251
+ for (const e of entries) {
207
252
  blockingEntries.push([
208
253
  e.index,
209
254
  {
210
255
  ...e.violation,
211
- message: `${e.violation.message} (security baseline ${repo}: ${guardName} finished migrating — no baseline tolerance)`,
256
+ message: `${e.violation.message} (security baseline ${repo}: allowed=${regression.baseline}, current=${regression.current})`,
212
257
  },
213
258
  ]);
214
259
  }
260
+ } else {
261
+ frozen += entries.length;
262
+ }
263
+ }
264
+ return { frozen, reduced };
265
+ }
266
+
267
+ // Strict mode also visits repos without current findings, so baseline headroom
268
+ // there fails too. Returns the headroom that counts as reduced.
269
+ function applyStrictHeadroom(args: {
270
+ readonly guardName: string;
271
+ readonly roots: RepoRootList;
272
+ readonly byRepo: FindingsByRepo;
273
+ readonly loadOnce: (repo: string) => SecurityBaselineLoad;
274
+ readonly firstSortKey: number;
275
+ readonly blockingEntries: BlockingEntry[];
276
+ }): number {
277
+ const { guardName, roots, byRepo, loadOnce, firstSortKey, blockingEntries } = args;
278
+ let strictIndex = 0;
279
+ let reduced = 0;
280
+ for (const root of roots) {
281
+ const baseline = loadOnce(root.name);
282
+ const byPath = byRepo.get(root.name);
283
+ if (baseline.kind === "invalid") {
284
+ // Repos with current findings already got this entry in the per-repo pass.
285
+ if (byPath) continue;
286
+ blockingEntries.push([firstSortKey + strictIndex++, invalidBaselineViolation(baseline)]);
215
287
  continue;
216
288
  }
217
- const current: Record<string, number> = {};
218
- for (const [relPath, entries] of byPath) current[relPath] = entries.length;
289
+ const current = byPath ? countsPerPath(byPath) : {};
219
290
  const baselineForGuard = baseline.findings[guardName] ?? {};
220
- const { regressions, reduced: repoReduced } = compareToBaseline(current, baselineForGuard);
221
- reduced += repoReduced;
222
- const regressionByPath = new Map(regressions.map((r) => [r.file, r]));
223
- for (const [relPath, entries] of byPath) {
224
- const regression = regressionByPath.get(relPath);
225
- if (regression) {
226
- for (const e of entries) {
227
- blockingEntries.push([
228
- e.index,
229
- {
230
- ...e.violation,
231
- message: `${e.violation.message} (security baseline ${repo}: allowed=${regression.baseline}, current=${regression.current})`,
232
- },
233
- ]);
234
- }
235
- } else {
236
- frozen += entries.length;
237
- }
291
+ const { reductions } = compareToBaseline(current, baselineForGuard);
292
+ if (!byPath) {
293
+ reduced += reductions.reduce((sum, r) => sum + (r.baseline - r.current), 0);
238
294
  }
295
+ for (const r of reductions) {
296
+ blockingEntries.push([
297
+ firstSortKey + strictIndex++,
298
+ {
299
+ file: join(root.absPath, r.file),
300
+ line: 1,
301
+ message: `Security baseline stale: ${root.name}/${r.file} allows ${r.baseline}, found ${r.current} — run \`--write-security-baseline\` and commit, otherwise the headroom covers new findings.`,
302
+ },
303
+ ]);
304
+ }
305
+ }
306
+ return reduced;
307
+ }
308
+
309
+ export function applySecurityBaseline(args: {
310
+ readonly guardName: string;
311
+ readonly violations: readonly GuardViolation[];
312
+ readonly roots: RepoRootList;
313
+ readonly cwd: string;
314
+ readonly load: (repo: string) => SecurityBaselineLoad;
315
+ /** Also fail on baseline headroom (current < baseline) across every root, not just repos with current findings. */
316
+ readonly strict?: boolean;
317
+ }): { readonly blocking: GuardViolation[]; readonly frozen: number; readonly reduced: number } {
318
+ const { guardName, violations, roots, cwd, load, strict } = args;
319
+
320
+ const blockingEntries: BlockingEntry[] = [];
321
+ const byRepo = groupFindingsByRepo(violations, roots, cwd, blockingEntries);
322
+
323
+ const loadCache = new Map<string, SecurityBaselineLoad>();
324
+ const loadOnce = (repo: string): SecurityBaselineLoad => {
325
+ const cached = loadCache.get(repo);
326
+ if (cached) return cached;
327
+ const result = load(repo);
328
+ loadCache.set(repo, result);
329
+ return result;
330
+ };
331
+
332
+ let frozen = 0;
333
+ let reduced = 0;
334
+
335
+ for (const [repo, byPath] of byRepo) {
336
+ const repoResult = applyRepoBaseline({
337
+ guardName,
338
+ repo,
339
+ byPath,
340
+ baseline: loadOnce(repo),
341
+ blockingEntries,
342
+ });
343
+ frozen += repoResult.frozen;
344
+ reduced += repoResult.reduced;
239
345
  }
240
346
 
241
347
  if (strict === true) {
242
- let strictIndex = 0;
243
- for (const root of roots) {
244
- const baseline = loadOnce(root.name);
245
- const byPath = byRepo.get(root.name);
246
- if (baseline.kind === "invalid") {
247
- // Repos with current findings already got this entry in the loop above.
248
- if (byPath) continue;
249
- blockingEntries.push([
250
- violations.length + strictIndex++,
251
- {
252
- file: baseline.file,
253
- line: 1,
254
- message: `Security baseline unreadable or invalid: ${baseline.reason}. Fix the file; a broken baseline must not release any findings.`,
255
- },
256
- ]);
257
- continue;
258
- }
259
- const current: Record<string, number> = {};
260
- if (byPath) {
261
- for (const [relPath, entries] of byPath) current[relPath] = entries.length;
262
- }
263
- const baselineForGuard = baseline.findings[guardName] ?? {};
264
- const { reductions } = compareToBaseline(current, baselineForGuard);
265
- if (!byPath) {
266
- reduced += reductions.reduce((sum, r) => sum + (r.baseline - r.current), 0);
267
- }
268
- for (const r of reductions) {
269
- blockingEntries.push([
270
- violations.length + strictIndex++,
271
- {
272
- file: join(root.absPath, r.file),
273
- line: 1,
274
- message: `Security baseline stale: ${root.name}/${r.file} allows ${r.baseline}, found ${r.current} — run \`--write-security-baseline\` and commit, otherwise the headroom covers new findings.`,
275
- },
276
- ]);
277
- }
278
- }
348
+ reduced += applyStrictHeadroom({
349
+ guardName,
350
+ roots,
351
+ byRepo,
352
+ loadOnce,
353
+ firstSortKey: violations.length,
354
+ blockingEntries,
355
+ });
279
356
  }
280
357
 
281
358
  blockingEntries.sort((a, b) => a[0] - b[0]);
@@ -314,7 +391,7 @@ export function buildSecurityBaseline(
314
391
  return {
315
392
  format: SECURITY_BASELINE_FORMAT,
316
393
  repo,
317
- generated: new Date().toISOString().slice(0, 10),
394
+ generated: Temporal.Now.instant().toString().slice(0, 10),
318
395
  total,
319
396
  ...(sortedHardFail.length > 0 ? { hardFail: sortedHardFail } : {}),
320
397
  findings: Object.fromEntries(Object.entries(findings).sort(([a], [b]) => a.localeCompare(b))),
@@ -174,6 +174,35 @@ function stripMarker(trimmed: string): string {
174
174
  return trimmed.slice(0, idx).trimEnd();
175
175
  }
176
176
 
177
+ type LineMarker = { readonly reason: string; readonly line: number };
178
+
179
+ // Documented hatch: marker on the call line or the line directly above.
180
+ // Each marker line suppresses at most one subsequent hit (consumed), so a
181
+ // second `.unsafe()` under the same comment is not silently covered.
182
+ function markerForLine(
183
+ lines: readonly string[],
184
+ i: number,
185
+ consumedMarkers: Set<number>,
186
+ ): LineMarker | undefined {
187
+ const sameLineReason = MARKER_WITH_REASON_RE.exec(lines[i] ?? "")?.[2];
188
+ if (sameLineReason !== undefined) return { reason: sameLineReason.trim(), line: i + 1 };
189
+ if (i === 0) return undefined;
190
+ const aboveReason = MARKER_WITH_REASON_RE.exec(lines[i - 1] ?? "")?.[2];
191
+ const markerIdx = i - 1;
192
+ if (aboveReason === undefined || consumedMarkers.has(markerIdx)) return undefined;
193
+ consumedMarkers.add(markerIdx);
194
+ return { reason: aboveReason.trim(), line: i };
195
+ }
196
+
197
+ function isCommentOnlyLine(trimmed: string): boolean {
198
+ return (
199
+ trimmed.startsWith("//") ||
200
+ trimmed.startsWith("*") ||
201
+ trimmed.startsWith("/**") ||
202
+ trimmed.startsWith("/*")
203
+ );
204
+ }
205
+
177
206
  function scanFileText(relPath: string, text: string, hits: SqlInventoryHit[]): void {
178
207
  const lines = text.split("\n");
179
208
  const consumedMarkers = new Set<number>();
@@ -185,34 +214,9 @@ function scanFileText(relPath: string, text: string, hits: SqlInventoryHit[]): v
185
214
  // a marker placed on its own comment line (the common case: the line
186
215
  // ABOVE the offending call) must still be seen even though that line
187
216
  // itself never reaches the pattern loop.
188
- //
189
- // Documented hatch: marker on the call line or the line directly above.
190
- // Each marker line suppresses at most one subsequent hit (consumed), so a
191
- // second `.unsafe()` under the same comment is not silently covered.
192
- let markerReason: string | undefined;
193
- let markerLine: number | undefined;
194
- const sameLineReason = MARKER_WITH_REASON_RE.exec(line)?.[2];
195
- if (sameLineReason !== undefined) {
196
- markerReason = sameLineReason.trim();
197
- markerLine = i + 1;
198
- } else if (i > 0) {
199
- const aboveReason = MARKER_WITH_REASON_RE.exec(lines[i - 1] ?? "")?.[2];
200
- const markerIdx = i - 1;
201
- if (aboveReason !== undefined && !consumedMarkers.has(markerIdx)) {
202
- markerReason = aboveReason.trim();
203
- markerLine = i;
204
- consumedMarkers.add(markerIdx);
205
- }
206
- }
217
+ const marker = markerForLine(lines, i, consumedMarkers);
207
218
 
208
- if (
209
- trimmed.startsWith("//") ||
210
- trimmed.startsWith("*") ||
211
- trimmed.startsWith("/**") ||
212
- trimmed.startsWith("/*")
213
- ) {
214
- continue;
215
- }
219
+ if (isCommentOnlyLine(trimmed)) continue;
216
220
  for (const { kind, re } of PATTERNS) {
217
221
  if (!re.test(line)) continue;
218
222
  hits.push({
@@ -220,10 +224,8 @@ function scanFileText(relPath: string, text: string, hits: SqlInventoryHit[]): v
220
224
  line: i + 1,
221
225
  kind,
222
226
  allowed: isRawSqlAllowed(relPath),
223
- markerSuppressed: markerReason !== undefined,
224
- ...(markerReason !== undefined && markerLine !== undefined
225
- ? { markerReason, markerLine }
226
- : {}),
227
+ markerSuppressed: marker !== undefined,
228
+ ...(marker !== undefined ? { markerReason: marker.reason, markerLine: marker.line } : {}),
227
229
  snippet: stripMarker(trimmed).slice(0, 120),
228
230
  });
229
231
  }
@@ -259,7 +261,7 @@ export async function scanRepo(
259
261
  }
260
262
 
261
263
  return {
262
- scannedAt: new Date().toISOString(),
264
+ scannedAt: Temporal.Now.instant().toString(),
263
265
  root: repoRoot,
264
266
  hits,
265
267
  scannedFiles: relFiles.length,
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.357.0",
4
+ "type": "fix",
5
+ "title": "comment-lang reports an unreadable or non-object baseline instead of crashing",
6
+ "detail": "`kumiko-guards comment-lang` with a baseline file that is not valid JSON, is `null` or has a `null` perFile now prints a baseline-format message and exits 1 instead of throwing a TypeError. A security baseline with the wrong shape now reports the reason `unexpected_baseline_shape`."
7
+ },
2
8
  {
3
9
  "version": "0.355.0",
4
10
  "type": "breaking",