@vantageos/backend-doctor 0.1.14 → 0.3.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.
- package/README.md +66 -5
- package/dist/args.d.ts +16 -0
- package/dist/args.js +72 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1198 -0
- package/dist/cli.js +249 -17
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage-cli.js +8 -0
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +28 -18
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +37 -22
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +93 -19
- package/dist/detectors/shared.js +8 -7
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +71 -18
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
- package/dist/detectors/source/guard-helper-flow.js +1216 -0
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +58 -2
- package/dist/detectors/source/identity.js +1158 -28
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +178 -4
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +661 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +358 -4
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.d.ts +16 -0
- package/dist/detectors/source/security.js +680 -33
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +48 -16
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +84 -48
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +74 -8
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +15 -13
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- package/package.json +2 -2
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { Detector } from "../../engine-types.js";
|
|
2
|
-
import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
|
|
2
|
+
import { type Scan, type Site, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
|
|
3
|
+
export declare const SCOPE_REFUSED_RETURN: RegExp;
|
|
4
|
+
export declare function noOrgEarlyReturn(text: string): boolean;
|
|
5
|
+
/** R-50's scan plus the public queries it found OUTSIDE its scope (no org-throw,
|
|
6
|
+
* no resolver, no marker — standard 4bcd605 :653). The out-of-scope list never
|
|
7
|
+
* reaches a verdict; it only words the empty-subject abstain truthfully. */
|
|
8
|
+
export interface R50Scan {
|
|
9
|
+
scan: Scan;
|
|
10
|
+
outOfScope: Site[];
|
|
11
|
+
}
|
|
3
12
|
export declare function scanR50(files: SrcFile[]): Scan;
|
|
13
|
+
export declare function scanR50Detailed(files: SrcFile[]): R50Scan;
|
|
4
14
|
export declare const R50: Detector;
|
|
5
15
|
/** In the form the orchestrator spreads into REGISTRY. */
|
|
6
16
|
export declare const SOURCE_ISOLATION_DETECTORS: SourceDetectorEntry[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN,
|
|
1
|
+
import { code, decide, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, } from "./_scan.js";
|
|
2
2
|
// A public reactive read is DEFINED, not read. R-50 governs a query DEFINITION —
|
|
3
3
|
// `export const NAME = <builder>({ ... })` — never a `ctx.db.query(...)` table READ
|
|
4
4
|
// inside a handler (the pre-fix opener `/\bquery\(/` matched `.query(` reads
|
|
@@ -194,6 +194,54 @@ function namedValueDomination(before) {
|
|
|
194
194
|
}
|
|
195
195
|
return false;
|
|
196
196
|
}
|
|
197
|
+
// Form C (VP shape — the typed-empty PRE-ORG path). `withOrgScope(ctx, {
|
|
198
|
+
// refuseWithoutThrow: true })` marks a signed-in caller with no organisation as
|
|
199
|
+
// `scope.refused` instead of throwing; the handler then answers that caller with a
|
|
200
|
+
// typed value BEFORE any org-throw:
|
|
201
|
+
// const scope = await withOrgScope(ctx, { refuseWithoutThrow: true });
|
|
202
|
+
// if (scope.refused) { return null; } // or `return [];` / `return { … }`
|
|
203
|
+
// if (!scope.isMaster && scope.orgSlug === null) throw new ConvexError("RBAC_DENIED…");
|
|
204
|
+
// The throw below the guard is then unreachable for the no-org caller R-50 protects
|
|
205
|
+
// (a mounted, subscribed shell); it fires only for a caller with no identity at all.
|
|
206
|
+
// NARROW on purpose: the guard must test a `.refused` flag (bare or on a binding)
|
|
207
|
+
// and its body must be a bare `return` of `null`, an empty array, or an object
|
|
208
|
+
// literal — never `return <identifier>`, never a call, never a throw.
|
|
209
|
+
export const SCOPE_REFUSED_RETURN = /if\s*\(\s*(?:[A-Za-z_$][\w$]*\.)?refused\s*\)\s*(?:\{\s*)?return\s+(?:null\b|\[\s*\]|\{)/;
|
|
210
|
+
// Form D (vCRM shape — an EARLY GUARD that answers the no-org caller with a typed
|
|
211
|
+
// value before the org resolver/throw is reached):
|
|
212
|
+
// if (!ctx.workspaceId) { return emptySummary(args.presetKey, CURRENCY); } reports.ts
|
|
213
|
+
// const workspaceId = requireWorkspaceId(ctx.workspaceId); (now unreachable on no-org)
|
|
214
|
+
// if (!ctx.workspaceId || !company || company.workspaceId !== ctx.workspaceId) { return []; } activities.ts
|
|
215
|
+
// NARROW on purpose: the condition must contain a no-org NEGATION as a disjunct and
|
|
216
|
+
// NO `&&` anywhere (a conjunction could leave the no-org caller to fall through),
|
|
217
|
+
// and the guarded statement must be a `return` of `null`, `[]`, an object literal,
|
|
218
|
+
// or a CALL whose name marks the empty/unauthorised case — never `return <ident>`,
|
|
219
|
+
// never an arbitrary call, never a throw.
|
|
220
|
+
const NO_ORG_NEGATION = /!\s*(?:scope\.|ctx\.[A-Za-z0-9_.]*)?(?:org|orgId|orgSlug|workspaceId|workspace)\b|\b(?:org|orgId|orgSlug|workspaceId)\s*(?:===|==)\s*(?:null|undefined)\b/;
|
|
221
|
+
const EARLY_TYPED_RETURN = /^\s*\{?\s*return\s+(?:null\b|\[\s*\]|\{|(?:[A-Za-z0-9_$]+\.)*[A-Za-z0-9_$]*(?:[Ee]mpty|[Uu]nauthori[sz]ed|NoOrg|NoWorkspace)[A-Za-z0-9_$]*\s*\()/;
|
|
222
|
+
export function noOrgEarlyReturn(text) {
|
|
223
|
+
const opener = /\bif\s*\(/g;
|
|
224
|
+
let m;
|
|
225
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
226
|
+
while ((m = opener.exec(text)) !== null) {
|
|
227
|
+
let depth = 1;
|
|
228
|
+
let j = m.index + m[0].length;
|
|
229
|
+
for (; j < text.length && depth > 0; j++) {
|
|
230
|
+
if (text[j] === "(")
|
|
231
|
+
depth += 1;
|
|
232
|
+
else if (text[j] === ")")
|
|
233
|
+
depth -= 1;
|
|
234
|
+
}
|
|
235
|
+
if (depth !== 0)
|
|
236
|
+
return false;
|
|
237
|
+
const cond = text.slice(m.index + m[0].length, j - 1);
|
|
238
|
+
if (cond.includes("&&") || !NO_ORG_NEGATION.test(cond))
|
|
239
|
+
continue;
|
|
240
|
+
if (EARLY_TYPED_RETURN.test(text.slice(j)))
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
197
245
|
function throwDomination(blockText) {
|
|
198
246
|
const t1 = ORG_THROW.exec(blockText);
|
|
199
247
|
const t2 = ORG_THROW_HELPER.exec(blockText);
|
|
@@ -204,6 +252,12 @@ function throwDomination(blockText) {
|
|
|
204
252
|
// Form B (CRM helper-returned value, guard bound to the value) — decisive on its own.
|
|
205
253
|
if (namedValueDomination(before))
|
|
206
254
|
return "dominated";
|
|
255
|
+
// Form C (VP pre-org `scope.refused` typed-empty return) — decisive on its own.
|
|
256
|
+
if (SCOPE_REFUSED_RETURN.test(before))
|
|
257
|
+
return "dominated";
|
|
258
|
+
// Form D (early no-org guard returning a typed value) — decisive on its own.
|
|
259
|
+
if (noOrgEarlyReturn(before))
|
|
260
|
+
return "dominated";
|
|
207
261
|
// Form A (inline typed-empty object literal + a no-org guard).
|
|
208
262
|
if (!TYPED_EMPTY_RETURN.test(before))
|
|
209
263
|
return "none";
|
|
@@ -232,6 +286,9 @@ function queryBlock(lines, i) {
|
|
|
232
286
|
return { lines: body, unterminated: seenOpen };
|
|
233
287
|
}
|
|
234
288
|
export function scanR50(files) {
|
|
289
|
+
return scanR50Detailed(files).scan;
|
|
290
|
+
}
|
|
291
|
+
export function scanR50Detailed(files) {
|
|
235
292
|
const scan = emptyScan();
|
|
236
293
|
// Pass 1: discover the tree's query/mutation builders so a customQuery wrapper
|
|
237
294
|
// (authedQuery, orgQuery, …) is recognised, not just the bare `query` base.
|
|
@@ -333,16 +390,37 @@ export function scanR50(files) {
|
|
|
333
390
|
const throwsOnNoOrg = ORG_THROW.test(blockText) || ORG_THROW_HELPER.test(blockText);
|
|
334
391
|
const hasResolver = SAFE_RESOLVER.test(blockText);
|
|
335
392
|
const hasMarker = CONTRACT_MARKER.test(blockTextWithComments);
|
|
393
|
+
if (!throwsOnNoOrg &&
|
|
394
|
+
!hasResolver &&
|
|
395
|
+
!hasMarker &&
|
|
396
|
+
noOrgEarlyReturn(blockText)) {
|
|
397
|
+
// No throw at all, and the no-org caller is answered by an early guard
|
|
398
|
+
// with a typed value: the standard's green verbatim (4bcd605 :653, "returns
|
|
399
|
+
// a typed empty/unauthorised context on the no-org path"). An R-50
|
|
400
|
+
// subject that conforms — not an out-of-scope read.
|
|
401
|
+
scan.conform.push({
|
|
402
|
+
rel: f.rel,
|
|
403
|
+
n: block.lines[0].n,
|
|
404
|
+
note: `${fn}: public query answers the no-org caller with a typed value from an early no-org guard (no throw anywhere in the handler) — the subscribing client receives a shape`,
|
|
405
|
+
});
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
336
408
|
if (!throwsOnNoOrg && !hasResolver && !hasMarker) {
|
|
337
|
-
// query does neither org-isolation throw nor fail-closed
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
409
|
+
// A query that does neither an org-isolation throw nor a fail-closed
|
|
410
|
+
// resolve nor declares a contract is OUT OF R-50's SCOPE — the pinned
|
|
411
|
+
// standard says so in its detector spec (backend-standard-4bcd605.md
|
|
412
|
+
// :653, "*out of scope:* … a query that does neither org-throw nor
|
|
413
|
+
// fail-closed resolve (not this rule's subject)"). R-50 asks how a
|
|
414
|
+
// REFUSAL is shaped; a read with no guard has no refusal to shape.
|
|
415
|
+
// Whether such a read resolves its caller AT ALL is CR-1's question
|
|
416
|
+
// (src/caller-resolution.ts — an unresolved public read is a CR-1
|
|
417
|
+
// NO-IDENTITY violation and drives the exit code), so the instrument
|
|
418
|
+
// still goes red on it, under the control that owns it. Counted only
|
|
419
|
+
// to name the empty-subject abstain truthfully.
|
|
342
420
|
ungated.push({
|
|
343
421
|
rel: f.rel,
|
|
344
422
|
n: block.lines[0].n,
|
|
345
|
-
note: `${fn}: public query with no org-throw, no fail-closed resolver,
|
|
423
|
+
note: `${fn}: public query with no org-throw, no fail-closed resolver, no isolation-contract marker — not an R-50 subject (standard 4bcd605 :653); caller resolution is CR-1's control`,
|
|
346
424
|
});
|
|
347
425
|
continue;
|
|
348
426
|
}
|
|
@@ -403,25 +481,28 @@ export function scanR50(files) {
|
|
|
403
481
|
}
|
|
404
482
|
}
|
|
405
483
|
}
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// "
|
|
409
|
-
//
|
|
410
|
-
// so
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
// never an abstention — whether the scan is otherwise empty (a fully-unmarked
|
|
415
|
-
// backend) OR mixed (bare reads alongside gated subjects). Laurent doctrine: "an
|
|
416
|
-
// instrument that cannot go red protects nothing; CNJ is for UNREADABLE input
|
|
417
|
-
// only." A bare read is fully READABLE — its two absences (no isolation, no
|
|
418
|
-
// contract) are the defect, not a could-not-read. Push to violate; the author's
|
|
419
|
-
// exit is a resolver, a typed-empty return, or a recorded isolation-contract marker.
|
|
420
|
-
for (const u of ungated)
|
|
421
|
-
scan.violate.push(u);
|
|
422
|
-
return scan;
|
|
484
|
+
// Unmarked public reads are NOT pushed anywhere: they are outside R-50's scope
|
|
485
|
+
// (standard 4bcd605 :653). Until 0.1.16 they were pushed to `violate` (the Day-158
|
|
486
|
+
// "an instrument that cannot go red protects nothing" correction), which predates
|
|
487
|
+
// CR-1; CR-1 now carries exactly that obligation for a read that resolves no
|
|
488
|
+
// caller, so keeping it here too double-counted it under a rule whose text
|
|
489
|
+
// excludes it. A tree whose ONLY public reads are unmarked abstains with the
|
|
490
|
+
// empty-subject reason below, naming the count — never a clean bill.
|
|
491
|
+
return { scan, outOfScope: ungated };
|
|
423
492
|
}
|
|
424
|
-
|
|
493
|
+
const R50_ID = "R-50 org isolation on a reactively-subscribed read returns a typed empty/unauthorized result, never an uncaught throw";
|
|
494
|
+
const R50_CHECK = "a public query that resolves org via a fail-closed non-throwing path or documents its client contract — it never throws on the missing-org path a subscribing client cannot catch";
|
|
495
|
+
export const R50 = {
|
|
496
|
+
id: R50_ID,
|
|
497
|
+
run: (tree) => {
|
|
498
|
+
const { scan, outOfScope } = scanR50Detailed(tree.files);
|
|
499
|
+
const n = outOfScope.length;
|
|
500
|
+
const empty = n > 0
|
|
501
|
+
? `R-50: no R-50 subject in the scanned tree — ${n} public query(ies) matched, none org-throws, resolves via withOrgScope, or carries an isolation-contract marker (out of scope per standard 4bcd605 :653; caller resolution is CR-1's control). NOT a clean bill.`
|
|
502
|
+
: "R-50: no public query matched in the scanned tree — no reactive read to certify. NOT a clean bill.";
|
|
503
|
+
return decide("R-50", R50_ID, R50_CHECK, scan, empty);
|
|
504
|
+
},
|
|
505
|
+
};
|
|
425
506
|
/** In the form the orchestrator spreads into REGISTRY. */
|
|
426
507
|
export const SOURCE_ISOLATION_DETECTORS = [
|
|
427
508
|
{ detector: R50, ruleId: "R-50", standardPin: SOURCE_STANDARD_PIN },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasJustificationTokenInText } from "../oracle-axis2.js";
|
|
1
2
|
import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
4
|
// R-19 — a table holding personal data declares a retention period AND exposes
|
|
@@ -92,7 +93,9 @@ export function scanR19(files) {
|
|
|
92
93
|
// file that collects unbounded or logs nothing is the violation.
|
|
93
94
|
// ---------------------------------------------------------------------------
|
|
94
95
|
const JOB_FILE = /recurring|monitor|sweep|poll|resolver|cleanup|digest|cron/i;
|
|
95
|
-
|
|
96
|
+
// `.paginate(` is a per-run bound too: a page of `numItems` + a self-reschedule is
|
|
97
|
+
// the platform's continuation pattern the standard names for R-20 (4bcd605 :256).
|
|
98
|
+
const RUN_BOUND = /\.take\s*\(|\.paginate\s*\(|_cap\b|\blimit\b/i;
|
|
96
99
|
const OUTCOME_LOG = /console\.(log|error|warn|info)\s*\(/;
|
|
97
100
|
// ACTUAL job content — a Convex data operation, a scheduler/cron registration, or
|
|
98
101
|
// a scheduled-function handler. Eta ruling (2026-08-31): a file is a background-job
|
|
@@ -108,10 +111,171 @@ function isJobFile(f) {
|
|
|
108
111
|
// require job CONTENT on an executing line, not a filename match alone.
|
|
109
112
|
return f.lines.some((l) => code(l) && JOB_CONTENT.test(l.raw));
|
|
110
113
|
}
|
|
114
|
+
// ---- REGISTRATION files are followed to their handlers ---------------------
|
|
115
|
+
//
|
|
116
|
+
// The misread: R-20 judged FILE BY FILE, so `convex/crons.ts` — which only
|
|
117
|
+
// REGISTERS schedules (`crons.interval("…", { minutes: 15 },
|
|
118
|
+
// internal.recurringTasks.processDueTasks, {})`) and does no work — was scored as an
|
|
119
|
+
// unbounded job because the registration file itself carries no `.take(`. The job
|
|
120
|
+
// is the HANDLER the registration names. A file whose job content is only cron
|
|
121
|
+
// registrations is therefore not judged as a job; each registration becomes a site
|
|
122
|
+
// judged on its handler's own body, following `ctx.runMutation/runAction/runQuery
|
|
123
|
+
// (internal.…)` delegations up to R20_MAX_HOP hops (a cron action that delegates
|
|
124
|
+
// its bounded drain to an internalMutation is bounded). A registration whose
|
|
125
|
+
// handler (or a delegate needed for the verdict) cannot be found in the tree is
|
|
126
|
+
// could-not-judge naming it — never green, never an accusation on a guess.
|
|
127
|
+
const CRON_FILE = /\bcronJobs\s*\(/;
|
|
128
|
+
const CRON_REGISTRATION = /\bcrons\.(?:interval|cron|hourly|daily|weekly|monthly)\s*\(/g;
|
|
129
|
+
const INTERNAL_REF = /\binternal\.([A-Za-z0-9_$.]+)/;
|
|
130
|
+
const DELEGATION = /ctx\.run(?:Mutation|Action|Query)\s*\(\s*internal\.([A-Za-z0-9_$.]+)/g;
|
|
131
|
+
const R20_MAX_HOP = 2;
|
|
132
|
+
/** A file whose executing job content is ONLY schedule registration. */
|
|
133
|
+
function isRegistrationOnly(f) {
|
|
134
|
+
const text = f.lines
|
|
135
|
+
.filter(code)
|
|
136
|
+
.map((l) => l.raw)
|
|
137
|
+
.join("\n");
|
|
138
|
+
if (!CRON_FILE.test(text))
|
|
139
|
+
return false;
|
|
140
|
+
return !/ctx\.db\.|\.collect\s*\(|\.paginate\s*\(|ctx\.scheduler\./.test(text);
|
|
141
|
+
}
|
|
142
|
+
/** Resolve `a.b.fn` (an `internal.` path) to the handler block's text + site. */
|
|
143
|
+
function findHandler(files, path) {
|
|
144
|
+
const parts = path.split(".");
|
|
145
|
+
if (parts.length < 2)
|
|
146
|
+
return undefined;
|
|
147
|
+
const fn = parts[parts.length - 1];
|
|
148
|
+
const mod = parts.slice(0, -1).join("/");
|
|
149
|
+
const file = files.find((f) => f.rel === `${mod}.ts` || f.rel.endsWith(`/${mod}.ts`));
|
|
150
|
+
if (!file)
|
|
151
|
+
return undefined;
|
|
152
|
+
const open = new RegExp(`export\\s+const\\s+${fn}\\s*=`);
|
|
153
|
+
const lines = file.lines;
|
|
154
|
+
const i = lines.findIndex((l) => code(l) && open.test(l.raw));
|
|
155
|
+
if (i < 0)
|
|
156
|
+
return undefined;
|
|
157
|
+
let depth = 0;
|
|
158
|
+
let started = false;
|
|
159
|
+
const body = [];
|
|
160
|
+
for (let j = i; j < lines.length; j++) {
|
|
161
|
+
if (code(lines[j]))
|
|
162
|
+
body.push(lines[j].raw);
|
|
163
|
+
for (const ch of lines[j].raw) {
|
|
164
|
+
if (ch === "{") {
|
|
165
|
+
depth++;
|
|
166
|
+
started = true;
|
|
167
|
+
}
|
|
168
|
+
else if (ch === "}")
|
|
169
|
+
depth--;
|
|
170
|
+
}
|
|
171
|
+
if (started && depth <= 0)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
return { rel: file.rel, n: lines[i].n, text: body.join("\n") };
|
|
175
|
+
}
|
|
176
|
+
function judgeHandler(files, path, depth, seen) {
|
|
177
|
+
const h = findHandler(files, path);
|
|
178
|
+
if (!h)
|
|
179
|
+
return { bound: false, log: false, unresolved: [path] };
|
|
180
|
+
const out = {
|
|
181
|
+
bound: RUN_BOUND.test(h.text),
|
|
182
|
+
log: OUTCOME_LOG.test(h.text),
|
|
183
|
+
unresolved: [],
|
|
184
|
+
};
|
|
185
|
+
if ((out.bound && out.log) || depth >= R20_MAX_HOP)
|
|
186
|
+
return out;
|
|
187
|
+
DELEGATION.lastIndex = 0;
|
|
188
|
+
let m = DELEGATION.exec(h.text);
|
|
189
|
+
while (m !== null) {
|
|
190
|
+
const next = m[1];
|
|
191
|
+
if (!seen.has(next)) {
|
|
192
|
+
seen.add(next);
|
|
193
|
+
const sub = judgeHandler(files, next, depth + 1, seen);
|
|
194
|
+
out.bound ||= sub.bound;
|
|
195
|
+
out.log ||= sub.log;
|
|
196
|
+
out.unresolved.push(...sub.unresolved);
|
|
197
|
+
}
|
|
198
|
+
m = DELEGATION.exec(h.text);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/** Each `crons.<kind>(…, internal.x.fn, …)` registration in a registration file,
|
|
203
|
+
* judged on its handler. */
|
|
204
|
+
function scanRegistrations(f, files, scan) {
|
|
205
|
+
const codeLines = f.lines.filter(code);
|
|
206
|
+
const text = codeLines.map((l) => l.raw).join("\n");
|
|
207
|
+
// map a text offset back to its source line number.
|
|
208
|
+
const starts = [];
|
|
209
|
+
let off = 0;
|
|
210
|
+
for (const l of codeLines) {
|
|
211
|
+
starts.push(off);
|
|
212
|
+
off += l.raw.length + 1;
|
|
213
|
+
}
|
|
214
|
+
const lineAt = (o) => {
|
|
215
|
+
let k = 0;
|
|
216
|
+
while (k + 1 < starts.length && starts[k + 1] <= o)
|
|
217
|
+
k++;
|
|
218
|
+
return codeLines[k].n;
|
|
219
|
+
};
|
|
220
|
+
CRON_REGISTRATION.lastIndex = 0;
|
|
221
|
+
let m = CRON_REGISTRATION.exec(text);
|
|
222
|
+
while (m !== null) {
|
|
223
|
+
const n = lineAt(m.index);
|
|
224
|
+
const ref = INTERNAL_REF.exec(text.slice(m.index, m.index + 400));
|
|
225
|
+
if (!ref) {
|
|
226
|
+
scan.cnj.push({
|
|
227
|
+
rel: f.rel,
|
|
228
|
+
n,
|
|
229
|
+
note: "schedule registration names no `internal.<module>.<fn>` handler readable at the site — its bound and observability cannot be followed (could-not-judge)",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
const path = ref[1];
|
|
234
|
+
const j = judgeHandler(files, path, 0, new Set([path]));
|
|
235
|
+
const h = findHandler(files, path);
|
|
236
|
+
const at = h ? `${h.rel}:${h.n}` : "not found";
|
|
237
|
+
if (j.bound && j.log) {
|
|
238
|
+
scan.conform.push({
|
|
239
|
+
rel: f.rel,
|
|
240
|
+
n,
|
|
241
|
+
note: `scheduled handler internal.${path} (${at}) bounds its run and logs its outcome (followed from the registration, delegations included)`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
else if (j.unresolved.length > 0) {
|
|
245
|
+
scan.cnj.push({
|
|
246
|
+
rel: f.rel,
|
|
247
|
+
n,
|
|
248
|
+
note: `scheduled handler internal.${path} (${at}): ${j.bound ? "bounded" : "no bound found"}${j.log ? ", logs" : ", no log found"} — but ${j.unresolved.map((u) => `internal.${u}`).join(", ")} could not be found in the tree, so the verdict cannot be completed (could-not-judge)`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else if (!j.bound) {
|
|
252
|
+
scan.violate.push({
|
|
253
|
+
rel: f.rel,
|
|
254
|
+
n,
|
|
255
|
+
note: `scheduled handler internal.${path} (${at}) declares no per-run bound (.take/_CAP/limit) in its body or its delegates — an unbounded run does not ship`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
scan.cnj.push({
|
|
260
|
+
rel: f.rel,
|
|
261
|
+
n,
|
|
262
|
+
note: `scheduled handler internal.${path} (${at}) bounds its run but no outcome log (console.*) found — observability not statically confirmed`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
m = CRON_REGISTRATION.exec(text);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
111
269
|
export function scanR20(files) {
|
|
112
270
|
const scan = emptyScan();
|
|
113
271
|
for (const f of files) {
|
|
114
|
-
if (!isJudgeable(f)
|
|
272
|
+
if (!isJudgeable(f))
|
|
273
|
+
continue;
|
|
274
|
+
if (isRegistrationOnly(f)) {
|
|
275
|
+
scanRegistrations(f, files, scan);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!isJobFile(f))
|
|
115
279
|
continue;
|
|
116
280
|
let boundLine = 0;
|
|
117
281
|
let logLine = 0;
|
|
@@ -137,11 +301,21 @@ export function scanR20(files) {
|
|
|
137
301
|
note: "background job declares no per-run bound (.take/_CAP/limit) — an unbounded run does not ship",
|
|
138
302
|
});
|
|
139
303
|
}
|
|
304
|
+
else if (hasJustificationTokenInText(f.lines.map((l) => l.raw).join("\n"))) {
|
|
305
|
+
// judgement retired (task k179jnhx, §23): a bound-but-unlogged job is
|
|
306
|
+
// flagged; the FILE's carried JUSTIFIÉ:/JUSTIFIED: token is the written
|
|
307
|
+
// justification — decided by presence, never a person's opinion of it.
|
|
308
|
+
scan.conform.push({
|
|
309
|
+
rel: f.rel,
|
|
310
|
+
n: boundLine,
|
|
311
|
+
note: "background job bounds its run; no outcome log (console.*) found, but the file carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
140
314
|
else {
|
|
141
|
-
scan.
|
|
315
|
+
scan.violate.push({
|
|
142
316
|
rel: f.rel,
|
|
143
317
|
n: boundLine,
|
|
144
|
-
note: "background job bounds its run but no outcome log (console.*) found — observability
|
|
318
|
+
note: "background job bounds its run but no outcome log (console.*) found, and no written JUSTIFIÉ:/JUSTIFIED: token in the file — observability owes a fix or a recorded justification",
|
|
145
319
|
});
|
|
146
320
|
}
|
|
147
321
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasJustificationTokenInText } from "../oracle-axis2.js";
|
|
1
2
|
import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
|
|
2
3
|
// An ADMIN-PROVISIONABLE resource: a config/template row an admin creates —
|
|
3
4
|
// profiles, clients, tenants, templates, mappings, filter rules, allowlists.
|
|
@@ -114,6 +115,7 @@ export function scanR43(files) {
|
|
|
114
115
|
const scan = emptyScan();
|
|
115
116
|
const dels = deletePaths(files);
|
|
116
117
|
const { creates, optOutFiles } = adminCreates(files);
|
|
118
|
+
const textByRel = new Map(files.map((f) => [f.rel, f.lines.map((l) => l.raw).join("\n")]));
|
|
117
119
|
for (const c of creates.values()) {
|
|
118
120
|
const del = dels.get(c.table);
|
|
119
121
|
if (del) {
|
|
@@ -130,11 +132,21 @@ export function scanR43(files) {
|
|
|
130
132
|
note: `admin resource ${c.table} has no delete/GC path but carries a written out-of-scope reason (allow-no-delete-path) in ${c.rel}`,
|
|
131
133
|
});
|
|
132
134
|
}
|
|
135
|
+
else if (hasJustificationTokenInText(textByRel.get(c.rel) ?? "")) {
|
|
136
|
+
// judgement retired (task k179jnhx, §23): the file's carried
|
|
137
|
+
// JUSTIFIÉ:/JUSTIFIED: token IS the written out-of-scope reason,
|
|
138
|
+
// decided by presence, never by a person's opinion of soundness.
|
|
139
|
+
scan.conform.push({
|
|
140
|
+
rel: c.rel,
|
|
141
|
+
n: c.n,
|
|
142
|
+
note: `admin resource ${c.table} has no delete/GC path, but ${c.rel} carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
133
145
|
else {
|
|
134
|
-
scan.
|
|
146
|
+
scan.violate.push({
|
|
135
147
|
rel: c.rel,
|
|
136
148
|
n: c.n,
|
|
137
|
-
note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason — admin-resource lifecycle asymmetry
|
|
149
|
+
note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason (allow-no-delete-path / JUSTIFIÉ: / JUSTIFIED:) — admin-resource lifecycle asymmetry owes a fix or a recorded justification`,
|
|
138
150
|
});
|
|
139
151
|
}
|
|
140
152
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Detector, DetectorResult } from "../../engine-types.js";
|
|
2
|
+
import { type Site, type SrcFile } from "./_scan.js";
|
|
3
|
+
export declare const CR2_ID = "CR-2 no application defines its own identity primitive (tenant resolution, membership, scope filtering, credential validation, identification by name); identity lives only in @vantageos/cloud-identity";
|
|
4
|
+
export declare const KIND: {
|
|
5
|
+
readonly tenant: "resolves a caller to a tenant or agent";
|
|
6
|
+
readonly membership: "asserts membership or role";
|
|
7
|
+
readonly scope: "filters by the caller's scope";
|
|
8
|
+
readonly credential: "validates a credential";
|
|
9
|
+
readonly name: "identifies an agent by name";
|
|
10
|
+
};
|
|
11
|
+
export type Kind = keyof typeof KIND;
|
|
12
|
+
/** The written justification, read FROM THE TARGET SOURCE at the site. */
|
|
13
|
+
export declare const EXEMPT_MARKER: RegExp;
|
|
14
|
+
export interface LocalIdentityScan {
|
|
15
|
+
/** files read (judged + unjudgeable), the population size. */
|
|
16
|
+
files: number;
|
|
17
|
+
violate: Site[];
|
|
18
|
+
exempt: Site[];
|
|
19
|
+
cnj: Site[];
|
|
20
|
+
}
|
|
21
|
+
export declare function scanLocalIdentity(files: SrcFile[]): LocalIdentityScan;
|
|
22
|
+
export declare function localIdentityResult(scan: LocalIdentityScan): DetectorResult;
|
|
23
|
+
/** The rendered section: EVERY violation, exemption and abstention is named. */
|
|
24
|
+
export declare function localIdentitySection(scan: LocalIdentityScan): string;
|
|
25
|
+
/** CR-2 as an engine Detector. */
|
|
26
|
+
export declare const CR2: Detector;
|