Skip to content

Commit 3635f2f

Browse files
committed
fix(observability-map): make audit-trail true, and stop one dead statement clearing auth-scope
Four defects from the whole-branch review, all of them the same shape: a list or a corpus that stopped keeping up with the code. AUDIT_SYMBOLS named nothing. auditLog, recordAudit and writeAuditEvent are exported nowhere in apps/webapp, packages/core or internal-packages, so the pass branch could never fire, and both renderers printed "No audit helper exists in the webapp" while models/admin.server.ts has been writing prisma.impersonationAuditLog.create rows on two paths all along. The list is now the three helpers that reach that write, and the AUDIT figure goes from 0 of 49 to 3 of 49. The pass branch had never been exercised against a real name either: its only test imported auditLog from a module that does not exist. It rotted because webappSymbols.test.ts covered every other name list in the package and not this one. That came first, and it fails on the old list. audit-trail was also the one check that did not follow the visibility rule the README states as universal. It gated on sensitivity and hasAction alone, so on resources.impersonation.ts, a four-statement body, auth-boundary declined to judge because any guard would be behind the import while audit-trail accused the route over an audit write behind that same import. It now takes the same exemption, with a known writer read before it so presence still counts where absence does not. auth-scope was defeated by a dead statement. The predicate fired on any property at all whose value was a caller id, wherever it sat, so prepending an unused object holding user.id under an arbitrary key to every body raised settings.sso and settings.team, the only two findings the check has ever produced and both confirmed cross-org exposures. The property name now has to be an identity field and the object has to be handed to a call. Two corpus entries cover both halves. And suppress-every-check emitted directives for four checks, not five: auth-scope was added a round after that entry was written. The corpus could not catch its own omission, because leaving a check out of the sweep lowers the score rather than raising it, so there is now an ungated registry assertion instead. Adding a check without extending the corpus turns pnpm test red.
1 parent a7e6a17 commit 3635f2f

11 files changed

Lines changed: 288 additions & 33 deletions

File tree

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,54 @@
11
import type { CheckResult, EntryPoint } from "../types.js";
22
import { classifySensitivity } from "../sensitivity.js";
3+
import { isTrivial } from "../triviality.js";
34

45
const ID = "audit-trail";
56

6-
const AUDIT_SYMBOLS = ["auditLog", "recordAudit", "writeAuditEvent"];
7+
/**
8+
* Calls that write a record of who did something.
9+
*
10+
* This list named nothing at all until it was checked. `auditLog`, `recordAudit` and
11+
* `writeAuditEvent` are exported nowhere in apps/webapp, packages/core or internal-packages,
12+
* so the pass branch below could never fire, every applicable route failed, and both renderers
13+
* printed "No audit helper exists in the webapp" while `models/admin.server.ts` was writing
14+
* `prisma.impersonationAuditLog.create({ action, adminId, targetId, ipAddress })` on two paths.
15+
* The rot went unnoticed because `webappSymbols.test.ts` covered every other name list in the
16+
* package and not this one. It covers this one now.
17+
*
18+
* All three names below reach that write: `redirectWithImpersonation` writes the START row,
19+
* `clearImpersonation` writes the STOP row, and `startImpersonation` returns one or the other.
20+
*
21+
* Two of them are also in `SENSITIVE_SYMBOLS`, which is worth saying out loud because it looks like
22+
* the circularity the sensitivity list was cleaned up to remove. It is not quite the same shape:
23+
* `requireAdminApiRequest` was a pure mitigation counted as a hazard, whereas impersonation
24+
* genuinely is the hazard AND genuinely writes the record. The consequence is real all the same, so
25+
* here it is: a route made sensitive only by one of these calls cannot fail this check, because the
26+
* call that put it in the cohort is the call that satisfies it.
27+
*
28+
* Matched against `importedNames` and `calleeNames`, so an import of one counts. Nothing matches
29+
* the underlying `prisma.impersonationAuditLog.create` path directly: `calleeNames` records
30+
* `create` for a member call, and no route in the tree writes the row itself.
31+
*/
32+
export const AUDIT_SYMBOLS = [
33+
"redirectWithImpersonation",
34+
"clearImpersonation",
35+
"startImpersonation",
36+
];
737

838
/**
9-
* Whether a sensitive mutation leaves a record of who did it. Nothing in the webapp writes one
10-
* today, so every applicable entry point fails: the check states the gap rather than measuring
11-
* variation between routes, which is why the score leaves it out.
39+
* Whether a sensitive mutation leaves a record of who did it.
40+
*
41+
* Applicability follows the same rule as every other check, which it did not before: would this
42+
* evidence necessarily be visible in the body if it existed? It gated on sensitivity and
43+
* `hasAction` alone, so on `resources.impersonation.ts`, a four-statement body, `auth-boundary`
44+
* declined to judge because any guard would be behind the import while this check accused the route
45+
* over an audit write behind that same import. Two checks, opposite verdicts, one fact.
46+
*
47+
* So a trivial body is not-applicable here too. A delegating one is handled centrally by
48+
* `scoreEntry`, which answers for every check before any of them runs, so there is no test for it
49+
* here. The order matters and mirrors
50+
* `auth-boundary`: a known audit call is read BEFORE the triviality exemption, because presence is
51+
* evidence even where absence is not.
1252
*/
1353
export const auditTrail = {
1454
id: ID,
@@ -18,8 +58,17 @@ export const auditTrail = {
1858
return { id: ID, status: "not-applicable", detail: "not a sensitive mutation" };
1959
}
2060
const symbols = new Set([...ep.importedNames, ...ep.calleeNames]);
21-
return AUDIT_SYMBOLS.some((s) => symbols.has(s))
22-
? { id: ID, status: "pass", detail: "records an audit event" }
23-
: { id: ID, status: "fail", detail: "sensitive mutation with no audit record" };
61+
if (AUDIT_SYMBOLS.some((s) => symbols.has(s))) {
62+
return { id: ID, status: "pass", detail: "records an audit event" };
63+
}
64+
if (isTrivial(ep)) {
65+
return {
66+
id: ID,
67+
status: "not-applicable",
68+
detail:
69+
"cannot verify: no privileged work in the body, any audit write is behind an import",
70+
};
71+
}
72+
return { id: ID, status: "fail", detail: "sensitive mutation with no audit record" };
2473
},
2574
};

internal-packages/observability-map/src/checks/authScope.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,13 @@ function builderExports(ep: EntryPoint): BuilderExport[] {
6565
* made this check agree with a route that resolves its target org from a URL slug and puts nothing
6666
* else in front of it.
6767
*
68-
* Applicable only where it is answerable: sensitive, builder-wrapped and not delegating. Outside
68+
* Applicable only where it is answerable: sensitive and builder-wrapped. Outside
6969
* that it would be a second near-universal fail, which is the shape the `request-context` figure
7070
* already has and which the report has to collapse rather than list. There is no triviality test
7171
* here because there is nothing left for one to refuse: `isTrivial` answers false for any route
72-
* with an initializer callee, so a builder-wrapped route is never trivial.
72+
* with an initializer callee, so a builder-wrapped route is never trivial. Nor is there a
73+
* delegating test: `scoreEntry` answers not-applicable for a delegating entry before any check
74+
* runs, so one here would be unreachable, and one WAS here saying otherwise.
7375
*
7476
* Three residuals, running in both directions.
7577
*
@@ -92,9 +94,6 @@ function builderExports(ep: EntryPoint): BuilderExport[] {
9294
export const authScope = {
9395
id: ID,
9496
run(ep: EntryPoint): CheckResult {
95-
if (ep.delegating) {
96-
return { id: ID, status: "not-applicable", detail: "delegates its body to another module" };
97-
}
9897
if (!classifySensitivity(ep).sensitive) {
9998
return { id: ID, status: "not-applicable", detail: "not sensitive" };
10099
}

internal-packages/observability-map/src/checks/index.test.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,18 +1387,61 @@ describe("audit-trail", () => {
13871387
expect(r.status).toBe("not-applicable");
13881388
});
13891389

1390-
it("passes a sensitive mutation that records an audit event", () => {
1390+
// The pass branch had never been exercised against a real name: the fixture imported `auditLog`
1391+
// from `~/services/audit.server`, a helper and a module that both exist nowhere. All three names
1392+
// on the list now reach `prisma.impersonationAuditLog.create` in `models/admin.server.ts`.
1393+
it.each(["redirectWithImpersonation", "clearImpersonation", "startImpersonation"])(
1394+
"passes a sensitive mutation that records an audit event through %s",
1395+
(writer) => {
1396+
const r = run(
1397+
"audit-trail",
1398+
"admin.impersonate.tsx",
1399+
`import { ${writer} } from "~/models/admin.server";
1400+
import { prisma } from "~/db.server";
1401+
export async function action({ request }) {
1402+
const target = await prisma.user.findFirst({ where: { admin: false } });
1403+
const session = await ${writer}(request, target.id, "/");
1404+
return session;
1405+
}`
1406+
);
1407+
expect(r.status).toBe("pass");
1408+
expect(r.detail).toBe("records an audit event");
1409+
}
1410+
);
1411+
1412+
it("still fails a sensitive mutation that writes no record", () => {
13911413
const r = run(
13921414
"audit-trail",
13931415
"api.v1.auth.jwt.ts",
1394-
`import { auditLog } from "~/services/audit.server";
1395-
import { prisma } from "~/db.server";
1416+
`import { prisma } from "~/db.server";
13961417
export async function action({ request }) {
1397-
const token = await prisma.token.create({ data: {} });
1398-
await auditLog("token.created", { tokenId: token.id });
1418+
const token = await prisma.token.create({ data: { name: request.url } });
13991419
return json(token);
14001420
}`
14011421
);
1422+
expect(r.status).toBe("fail");
1423+
});
1424+
1425+
// The coherence fix. `auth-boundary` declines to judge a trivial body because a guard would be
1426+
// behind the import; this check accused the same body over an audit write behind the same
1427+
// import. Same rule now, and presence is still read before the exemption, so a trivial body that
1428+
// does call a writer passes rather than sitting out.
1429+
it("declines to judge a trivial sensitive mutation, as auth-boundary does", () => {
1430+
const source = `import { doTheThing } from "~/models/admin.server";
1431+
export async function action({ request }) { return doTheThing(request, "/admin"); }`;
1432+
expect(run("audit-trail", "resources.impersonation.ts", source).status).toBe("not-applicable");
1433+
expect(run("auth-boundary", "resources.impersonation.ts", source).status).toBe(
1434+
"not-applicable"
1435+
);
1436+
});
1437+
1438+
it("still passes a trivial sensitive mutation that calls a writer", () => {
1439+
const r = run(
1440+
"audit-trail",
1441+
"resources.impersonation.ts",
1442+
`import { clearImpersonation } from "~/models/admin.server";
1443+
export async function action({ request }) { return clearImpersonation(request, "/admin"); }`
1444+
);
14021445
expect(r.status).toBe("pass");
14031446
});
14041447
});
@@ -1642,6 +1685,21 @@ describe("auth-scope", () => {
16421685
expect(r.detail).toContain("action (createActionPATApiRoute)");
16431686
});
16441687

1688+
// Round D item 3, at the check level: the shape that cleared both real findings.
1689+
it("is not cleared by a dead object holding the caller id", () => {
1690+
const r = run(
1691+
"auth-scope",
1692+
"api.v1.orgs.$orgParam.members.ts",
1693+
`import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
1694+
import { prisma } from "~/db.server";
1695+
export const action = createActionPATApiRoute({ method: "POST" }, async ({ user, params }) => {
1696+
const unused = { userId: user.id };
1697+
return json(await prisma.orgMember.deleteMany({ where: { slug: params.orgParam } }));
1698+
});`
1699+
);
1700+
expect(r.status).toBe("fail");
1701+
});
1702+
16451703
it("is not applicable to a route that is not sensitive", () => {
16461704
const r = run(
16471705
"auth-scope",

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
44
import { scanDirectory } from "./scan.js";
55
import { buildReport } from "./score.js";
66
import { ADDITIVE_IDS, MUTATIONS, type Mutation } from "./mutations.js";
7+
import { CHECKS } from "./checks/index.js";
78

89
/**
910
* The tree-scale mutation corpus.
@@ -41,7 +42,7 @@ const ENABLED = process.env.OBS_MAP_MUTATION_CORPUS === "1";
4142
*
4243
* `dead-classifying-try-with-call` is the shape `dead-classifying-try` only looked like it closed.
4344
* `canRaise` accepts any call at all, so `try { String(0); }` reads as a clause guarding real work
44-
* and takes the tree from 15 to 42, raising 224 routes, exactly as `try { 0; }` did before it was
45+
* and takes the tree from 19 to 44, raising 224 routes, exactly as `try { 0; }` did before it was
4546
* refused. Telling an inert call from one that can throw needs types the scanner does not have.
4647
* The docstrings in `scan.ts`, `types.ts` and `errorClassification.ts` say the rule refuses
4748
* `try { 0; }` and is defeated by one call, rather than claiming the family is closed.
@@ -195,6 +196,30 @@ function mutate(
195196
return { files: out, changed, sites };
196197
}
197198

199+
/**
200+
* Deliberately NOT gated behind `OBS_MAP_MUTATION_CORPUS`, unlike everything below it.
201+
*
202+
* This is the guard for the way the corpus actually failed. `auth-scope` was added a round after
203+
* `suppress-every-check` was written and never added to its directive list, so the "a suppression
204+
* cannot raise a score" invariant went untested at tree scale for the 19 routes that check applies
205+
* to, while the entry's own description said "every check". Nothing noticed, because the entry
206+
* still passed: omitting a check from the sweep leaves its failures in place, which lowers the
207+
* score rather than raising it, so the corpus cannot catch its own omission by failing.
208+
*
209+
* A registry assertion can, and it belongs in the default suite so that adding a check without
210+
* extending the corpus turns `pnpm test` red rather than a job nobody runs locally.
211+
*/
212+
describe("the corpus keeps up with the check registry", () => {
213+
it("suppresses every registered check in the exhaustive sweep", () => {
214+
const sweep = MUTATIONS.find((m) => m.id === "suppress-every-check")!;
215+
const mutated = sweep.apply("api.v1.a.ts", "export const loader = () => null;")!.source;
216+
const missing = CHECKS.map((c) => c.id).filter(
217+
(id) => !mutated.includes(`obs-map-disable ${id} `)
218+
);
219+
expect(missing).toEqual([]);
220+
});
221+
});
222+
198223
const describeCorpus = ENABLED && existsSync(ROUTES) ? describe : describe.skip;
199224

200225
/**
@@ -242,7 +267,7 @@ describeCorpus("mutation corpus over the real route tree", { timeout: ENTRY_TIME
242267
*
243268
* The guard the design asked for, verdict movement, cannot be used, though not for the reason an
244269
* earlier version of this comment gave. Plenty of defended entries move verdicts hard:
245-
* `delete-every-catch` takes the tree from 15 to 2 and `dead-throw-after-switch` to 6. The
270+
* `delete-every-catch` takes the tree from 19 to 8 and `dead-throw-after-switch` to 10. The
246271
* narrower true reason is that the IDEAL defended shape is one the scanner is blind to, and those
247272
* move nothing at all: `dead-if-false` and the ten entries beside it are defended precisely
248273
* because the tree comes out identical. Requiring movement would fail exactly the entries that

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,10 +421,14 @@ export const MUTATIONS: Mutation[] = [
421421
prependToEveryFile(
422422
"suppress-every-check",
423423
"prepend an obs-map-disable directive for every check to every file",
424+
// Every check, which this said it was and was not: `auth-scope` was added a round after the
425+
// entry was written and never added here, so the "a suppression cannot raise a score"
426+
// invariant went untested at tree scale for the 19 routes it applies to.
424427
[
425428
"// obs-map-disable error-classification -- mutation corpus",
426429
"// obs-map-disable request-context -- mutation corpus",
427430
"// obs-map-disable auth-boundary -- mutation corpus",
431+
"// obs-map-disable auth-scope -- mutation corpus",
428432
"// obs-map-disable audit-trail -- mutation corpus",
429433
].join("\n")
430434
),
@@ -522,6 +526,22 @@ export const MUTATIONS: Mutation[] = [
522526
"return obsMapResult.map(async () => {",
523527
"});"
524528
),
529+
// Round D item 3. `auth-scope` fired on any property at all whose value was a caller id, wherever
530+
// it sat, so one dead statement at the head of a body cleared it. These are the two halves: the
531+
// wrong property name, and the right property name in an object nothing is handed. Both raised
532+
// `settings.sso` and `settings.team`, the only two findings the check has ever produced.
533+
wrapEveryBody(
534+
"dead-caller-scope-object",
535+
"prepend a dead object holding the caller id under an arbitrary key to every route body",
536+
"const obsMapDeadScope = { anything: user.id };",
537+
""
538+
),
539+
wrapEveryBody(
540+
"dead-caller-scope-userid",
541+
"prepend a dead object holding the caller id under userId to every route body",
542+
"const obsMapDeadUserId = { userId: user.id };",
543+
""
544+
),
525545
// C1a. `auth-boundary` matched `/^(require|authenticate)/`, so any callee at all beginning
526546
// `require` cleared a sensitive route. These two prepend the shapes that paid: an invented guard
527547
// and a real helper whose name merely contains "Authenticated" while it does a lookup by id.
@@ -850,6 +870,8 @@ export const ADDITIVE_IDS = [
850870
"registered-throw",
851871
"fake-require-guard",
852872
"fake-authenticated-lookup",
873+
"dead-caller-scope-object",
874+
"dead-caller-scope-userid",
853875
];
854876

855877
function isSingleConst(statement: ts.Statement): statement is ts.VariableStatement {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,11 +464,11 @@ describe("hasDelta", () => {
464464
});
465465

466466
it("is true when the audit gap closed, which no score reports", () => {
467-
const audited = `import { auditLog } from "~/services/audit.server";
467+
const audited = `import { clearImpersonation } from "~/models/admin.server";
468468
import { prisma } from "~/db.server";
469469
export async function action() {
470470
const token = await prisma.token.create({ data: {} });
471-
await auditLog("token.created", { tokenId: token.id });
471+
await clearImpersonation(request, "/admin");
472472
return json(token);
473473
}`;
474474
const unaudited = `import { prisma } from "~/db.server";

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,11 @@ describe("rendering honestly when there is nothing to say", () => {
181181
it("does not claim no audit helper exists when one is in use", () => {
182182
const audited = scanFile(
183183
"api.v1.auth.tokens.ts",
184-
`import { auditLog } from "~/services/audit.server";
184+
`import { clearImpersonation } from "~/models/admin.server";
185185
import { prisma } from "~/db.server";
186186
export async function action() {
187187
const token = await prisma.token.create({ data: {} });
188-
await auditLog("token.created", { tokenId: token.id });
188+
await clearImpersonation(request, "/admin");
189189
return json(token);
190190
}`
191191
)!;

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

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ describe("scanFile: catch clause evidence", () => {
809809
// The ordering is the whole trick and it is easy to get backwards. The flag is raised at the
810810
// END of each statement, after that statement's own branch check. Raising it first makes every
811811
// deciding statement refuse itself, because `if (e instanceof X) return y` contains an exit by
812-
// definition; that variant was measured and it takes the tree from 15 to 6 and accuses 78
812+
// definition; that variant was measured against the pre-round-C tree and it takes it from 15 to 6, accusing 78
813813
// routes. This one leaves the real-tree report and all 240 clauses' evidence byte-identical.
814814
const BRANCH_EXITED: Array<[string, string]> = [
815815
...EXITED,
@@ -889,7 +889,7 @@ describe("scanFile: catch clause evidence", () => {
889889

890890
// S2. A clause whose try block cannot throw is unreachable, so it is not error handling and
891891
// nothing should be read off it. Crediting one was the largest hole ever found here: prepending
892-
// this to a body took the real tree from 15 to 42 and raised 224 routes, because the 261 routes
892+
// this to a body takes the real tree from 19 to 44 and raises 224 routes, because the routes
893893
// that catch nothing sat at `not-applicable` and a dead clause moved every one of them to `pass`.
894894
// `dead-classifying-try` in the mutation corpus is the tree-scale version.
895895
describe("a catch over a try block that cannot throw", () => {
@@ -2181,6 +2181,44 @@ describe("scanFile: the signals auth-scope reads", () => {
21812181
expect(ep!.actionScopesByCaller).toBe(false);
21822182
});
21832183

2184+
// Round D item 3. The predicate fired on any property at all whose value was a caller id, so one
2185+
// dead statement cleared the check. Both halves are now required: an identity property name, and
2186+
// an object that is handed to a call.
2187+
it("does not read a dead object holding the caller id as a scope", () => {
2188+
const arbitraryKey = scanFile(
2189+
"api.v1.orgs.ts",
2190+
`${PAT}
2191+
export const loader = createActionPATApiRoute({}, async ({ user }) => {
2192+
const unused = { anything: user.id };
2193+
return json(await prisma.org.findMany({ where: { slug: "x" } }));
2194+
});`
2195+
);
2196+
expect(arbitraryKey!.loaderScopesByCaller).toBe(false);
2197+
2198+
const identityKey = scanFile(
2199+
"api.v1.orgs.ts",
2200+
`${PAT}
2201+
export const loader = createActionPATApiRoute({}, async ({ user }) => {
2202+
const unused = { userId: user.id };
2203+
return json(await prisma.org.findMany({ where: { slug: "x" } }));
2204+
});`
2205+
);
2206+
expect(identityKey!.loaderScopesByCaller).toBe(false);
2207+
});
2208+
2209+
it("reads the caller id through any depth of nesting inside a call argument", () => {
2210+
const ep = scanFile(
2211+
"api.v1.orgs.ts",
2212+
`${PAT}
2213+
export const loader = createActionPATApiRoute({}, async ({ user }) => {
2214+
return json(await prisma.org.findMany({
2215+
where: { OR: [{ members: { some: { userId: user.id } } }] },
2216+
}));
2217+
});`
2218+
);
2219+
expect(ep!.loaderScopesByCaller).toBe(true);
2220+
});
2221+
21842222
it("does not read a non-identity field off the caller as a scope", () => {
21852223
const ep = scanFile(
21862224
"api.v1.orgs.ts",

0 commit comments

Comments
 (0)