Skip to content

Commit 2de22e4

Browse files
committed
refactor(observability-map): share or pin the rest of the duplicated rules
Sweeping the package for the defect behind the two review threads: one question answered by two pieces of code, where only one copy gets fixed. Shared: - the scannable-file predicate, copied into integration.test.ts and webappSymbols.test.ts after it was exported to stop mutationCorpus.test.ts copying it - the FIX FIRST filter and sort, byte-identical in terminal.ts and prComment.ts, which already imports five helpers from it; failingIds is now scoredFailures plus a map - normalizeSegment, in the test that validates SENSITIVE_SEGMENTS against the real tree. It was splitting segments with /_+$/, the regex that function's own comment says not to use - the five bare-literal node kinds, written out in literalTruth three lines above the literalValue that already had them Pinned: - contextGap and auditGap, which redo by hand what checkContributions computes generically, on two headline figures with nothing saying they had to agree. Reverting either to a different denominator or numerator now goes red Left alone, with reasons recorded in the sweep report: canRaise vs tryBlockMayThrow, the two exact true-keyword folds, the two comment extractors, the three means, ratio vs globalWithout, and the eight AST helpers mutations.ts keeps its own copies of so the corpus can disagree with the scanner.
1 parent 4f80d58 commit 2de22e4

5 files changed

Lines changed: 102 additions & 24 deletions

File tree

internal-packages/observability-map/src/report/prComment.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import type { MapReport, ScoredEntry } from "../score.js";
2-
import { SCORED_CHECK_IDS } from "../checks/index.js";
32
import {
43
auditLine,
54
checkContributionLines,
65
contextLine,
7-
contextOnly,
6+
fixFirst,
87
delegatedLines,
98
scoredFailures,
109
unknownSuppressionLines,
@@ -40,8 +39,7 @@ const MAX_DELEGATED_ROUTES = 15;
4039
// Scored checks only, same exclusion terminal.ts's scoredFailures makes: audit-trail fails almost
4140
// every sensitive mutation today, so listing it per route would nag with something unfixable
4241
// instead of surfacing the route-specific gaps this column exists for.
43-
const failingIds = (e: ScoredEntry) =>
44-
e.checks.filter((c) => SCORED_CHECK_IDS.includes(c.id) && c.status === "fail").map((c) => c.id);
42+
const failingIds = (e: ScoredEntry) => scoredFailures(e).map((c) => c.id);
4543

4644
function scoreLine(head: MapReport, base: MapReport | null): string {
4745
const headline =
@@ -197,14 +195,7 @@ function whatChangedSection(head: MapReport, base: MapReport | null): string[] {
197195

198196
function fixFirstSection(head: MapReport): string[] {
199197
const lines = ["FIX FIRST"];
200-
const worst = head.entries
201-
.filter((e) => scoredFailures(e).length > 0 && !contextOnly(e))
202-
.sort(
203-
(a, b) =>
204-
Number(b.sensitive) - Number(a.sensitive) ||
205-
a.score - b.score ||
206-
a.fileName.localeCompare(b.fileName)
207-
);
198+
const worst = fixFirst(head.entries);
208199

209200
for (const e of worst.slice(0, 3)) {
210201
const marks = e.sensitive ? " (sensitive)" : "";

internal-packages/observability-map/src/report/terminal.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,25 @@ export const scoredFailures = (e: ScoredEntry) =>
2727
* An entry that fails something else as well stays in the list with all of its findings, so a
2828
* route like `/account/tokens` still shows the request-context gap alongside the rest.
2929
*/
30+
/**
31+
* The routes the FIX FIRST list is drawn from, worst first: sensitive before not, then by score,
32+
* then by name. Exported because `prComment.ts` renders the same list with different bullets and
33+
* had a byte-identical copy of this filter and sort, in a file that already imports
34+
* `scoredFailures` and `contextOnly` from here.
35+
*
36+
* `contextOnly` routes are left out because `request-context` fails almost everything, so a list
37+
* headed by three of them tells a reader nothing they cannot read off the gap figure.
38+
*/
39+
export const fixFirst = (entries: ScoredEntry[]): ScoredEntry[] =>
40+
entries
41+
.filter((e) => scoredFailures(e).length > 0 && !contextOnly(e))
42+
.sort(
43+
(a, b) =>
44+
Number(b.sensitive) - Number(a.sensitive) ||
45+
a.score - b.score ||
46+
a.fileName.localeCompare(b.fileName)
47+
);
48+
3049
export const contextOnly = (e: ScoredEntry) => {
3150
const failures = scoredFailures(e);
3251
return failures.length === 1 && failures[0]!.id === "request-context";
@@ -197,14 +216,7 @@ export function renderTerminal(report: MapReport): string {
197216
);
198217
}
199218

200-
const worst = report.entries
201-
.filter((e) => scoredFailures(e).length > 0 && !contextOnly(e))
202-
.sort(
203-
(a, b) =>
204-
Number(b.sensitive) - Number(a.sensitive) ||
205-
a.score - b.score ||
206-
a.fileName.localeCompare(b.fileName)
207-
);
219+
const worst = fixFirst(report.entries);
208220

209221
lines.push("");
210222
lines.push("FIX FIRST");

internal-packages/observability-map/src/score.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,69 @@ export async function action() {
561561
expect(after.unmeasured).toBe(0);
562562
});
563563
});
564+
565+
/**
566+
* `contextGap` and `auditGap` are the same arithmetic `checkContributions` already does for every
567+
* check, written out again by hand for two named ids: `map(find).filter(status)` for the context
568+
* figure, `filter(some)` for the audit one, and a third spelling of "passed" for each. Three
569+
* implementations of "applicable, and how many of those passed", and nothing said they had to
570+
* agree, on the two figures the report puts in front of a reader as headline numbers.
571+
*
572+
* Pinned rather than shared. Collapsing them would mean the gap figures reading their check's row
573+
* out of `checkContributions`, which is a fine refactor and a wider blast radius than the property
574+
* is worth: what matters is that they cannot disagree, and an assertion says that without moving
575+
* any code the renderers read.
576+
*/
577+
describe("the hand-rolled gap figures agree with the per-check contributions", () => {
578+
const SOURCE = `import { prisma } from "~/db.server";
579+
import { logger } from "~/services/logger.server";
580+
export async function action({ params }) {
581+
try {
582+
return await prisma.apiKey.create({ data: { orgId: params.orgId } });
583+
} catch (e) {
584+
logger.error("failed", { orgId: params.orgId });
585+
return null;
586+
}
587+
}`;
588+
589+
// A sensitive mutation that DOES record an audit event, so `withAudit` is not simply
590+
// `sensitiveMutations`. Without it the audit assertion held whatever the numerator counted.
591+
const AUDITED = `import { prisma } from "~/db.server";
592+
import { startImpersonation } from "~/models/admin.server";
593+
export async function action({ request, params }) {
594+
const session = await startImpersonation(request, params.userId);
595+
await prisma.apiKey.create({ data: { orgId: params.orgId } });
596+
return redirect("/", { headers: session });
597+
}`;
598+
599+
const report = buildReport(
600+
[
601+
scanFile("api.v1.orgs.$orgId.apikeys.ts", SOURCE)!,
602+
scanFile("api.v1.tokens.ts", SOURCE)!,
603+
scanFile("resources.impersonation.ts", AUDITED)!,
604+
scanFile("healthcheck.ts", `export const loader = () => new Response("ok");`)!,
605+
],
606+
[]
607+
);
608+
609+
const contribution = (id: string) => report.checkContributions.find((c) => c.id === id)!;
610+
611+
it("reports the same request-context denominator and numerator", () => {
612+
expect(report.contextGap.applicable).toBe(contribution("request-context").applicable);
613+
expect(report.contextGap.naming).toBe(contribution("request-context").passed);
614+
});
615+
616+
it("reports the same audit-trail denominator and numerator", () => {
617+
expect(report.auditGap.sensitiveMutations).toBe(contribution("audit-trail").applicable);
618+
expect(report.auditGap.withAudit).toBe(contribution("audit-trail").passed);
619+
});
620+
621+
// A denominator of zero would make both assertions above hold vacuously.
622+
// Both assertions above hold vacuously on a zero denominator, and the audit one holds vacuously
623+
// whenever every applicable route fails, since the two counts coincide.
624+
it("measured something for both of them, with the audit numerator strictly between", () => {
625+
expect(report.contextGap.applicable).toBeGreaterThan(0);
626+
expect(report.auditGap.withAudit).toBeGreaterThan(0);
627+
expect(report.auditGap.withAudit).toBeLessThan(report.auditGap.sensitiveMutations);
628+
});
629+
});

internal-packages/observability-map/src/sensitivity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export const SENSITIVE_SEGMENTS = [
122122
* written the way a reader would say it. A trailing underscore opts a route out of its parent
123123
* layout (`resources.impersonation_.view-as.ts`) and changes nothing about what the route does.
124124
*/
125-
function normalizeSegment(segment: string): string {
125+
export function normalizeSegment(segment: string): string {
126126
// Trimmed by hand rather than with /_+$/, which backtracks polynomially on a run of underscores
127127
// and trips CodeQL. Nothing here is attacker-controlled (the input is a filename read off disk),
128128
// so this is about not spending a reviewer's attention on the alert.

internal-packages/observability-map/src/webappSymbols.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { readdirSync, readFileSync } from "node:fs";
33
import { join, resolve } from "node:path";
44
import { AUDIT_SYMBOLS } from "./checks/auditTrail.js";
55
import { GUARDS, SOFT_GUARDS } from "./checks/authBoundary.js";
6-
import { ANTICIPATED_SEGMENTS, SENSITIVE_SEGMENTS, SENSITIVE_SYMBOLS } from "./sensitivity.js";
6+
import { isScannableFile } from "./scan.js";
7+
import {
8+
ANTICIPATED_SEGMENTS,
9+
normalizeSegment,
10+
SENSITIVE_SEGMENTS,
11+
SENSITIVE_SYMBOLS,
12+
} from "./sensitivity.js";
713

814
/**
915
* Every name and every path segment the tool matches on must exist in the codebase it is pointed
@@ -82,7 +88,7 @@ function walkFiles(dir: string, out: string[] = []): string[] {
8288
for (const entry of readdirSync(dir, { withFileTypes: true })) {
8389
const path = join(dir, entry.name);
8490
if (entry.isDirectory()) walkFiles(path, out);
85-
else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) out.push(path);
91+
else if (isScannableFile(entry.name)) out.push(path);
8692
}
8793
return out;
8894
}
@@ -137,7 +143,10 @@ function routeSegments(): Set<string> {
137143
const segments = new Set<string>();
138144
for (const entry of readdirSync(ROUTES, { withFileTypes: true })) {
139145
for (const part of entry.name.replace(/\.tsx?$/, "").split(".")) {
140-
segments.add(part.replace(/_+$/, ""));
146+
// `sensitivity.ts`'s own normalizer. This validates the vocabulary that file matches on, so
147+
// a segment has to be trimmed here exactly as it is trimmed there; the local `/_+$/` was
148+
// also the regex `normalizeSegment`'s own comment says not to use.
149+
segments.add(normalizeSegment(part));
141150
}
142151
}
143152
return segments;

0 commit comments

Comments
 (0)