@cosmicdrift/kumiko-guards 0.353.0 → 0.354.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/package.json +2 -2
- package/src/_lib/guard-kit.ts +1 -1
- package/src/_lib/ignore-tag.ts +18 -1
- package/src/_lib/scan-scope.ts +4 -1
- package/src/changes.json +12 -0
- package/src/check-complexity.ts +2 -2
- package/src/check-real-provider-isolation.ts +34 -6
- package/src/check-secret-literals.ts +3 -1
- package/src/guard-access-denied-test.ts +8 -1
- package/src/guard-admin-api.ts +2 -2
- package/src/guard-comment-lang.ts +2 -1
- package/src/guard-cross-feature-imports.ts +3 -2
- package/src/guard-direct-entity-writes.ts +18 -5
- package/src/guard-direct-fetch.ts +17 -7
- package/src/guard-escape-hatch-declared.ts +8 -1
- package/src/guard-html-escape.ts +2 -1
- package/src/guard-no-direct-fs.ts +2 -0
- package/src/guard-open-to-all-reason.ts +8 -1
- package/src/guard-primitives-discipline.ts +2 -1
- package/src/guard-restricted-symbols.ts +2 -0
- package/src/guard-tenant-escalation.ts +2 -1
- package/src/guard-unsafe-json-parse.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.354.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.354.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "./security-baseline";
|
|
33
33
|
|
|
34
34
|
export { type BaselineRegression, compareToBaseline, findRepoRootFor } from "./baseline-compare";
|
|
35
|
-
export type
|
|
35
|
+
export { ALL_REPO_KINDS, type ScanExtension, type ScanScope, type ScanSpec } from "./scan-scope";
|
|
36
36
|
|
|
37
37
|
export type GuardViolation = {
|
|
38
38
|
readonly file: string;
|
package/src/_lib/ignore-tag.ts
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { type Node, SyntaxKind } from "ts-morph";
|
|
2
2
|
|
|
3
|
+
function escapeRegExp(text: string): string {
|
|
4
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** True when `line` carries `tag` (`<prefix> <slug>`), either alone or as one
|
|
8
|
+
* entry of a comma-separated slug list: `// kumiko-lint-ignore a,b <reason>`. */
|
|
9
|
+
export function lineHasIgnoreTag(line: string, tag: string): boolean {
|
|
10
|
+
if (line.includes(tag)) return true;
|
|
11
|
+
const slugStart = tag.lastIndexOf(" ");
|
|
12
|
+
if (slugStart < 0) return false;
|
|
13
|
+
const prefix = escapeRegExp(tag.slice(0, slugStart));
|
|
14
|
+
const slug = escapeRegExp(tag.slice(slugStart + 1));
|
|
15
|
+
return new RegExp(`${prefix}\\s+(?:[\\w-]+,)*${slug}(?=[\\s,]|$)`).test(line);
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
function lineHasTag(node: Node, line: number, tag: string): boolean {
|
|
4
19
|
const lines = node.getSourceFile().getFullText().split("\n");
|
|
5
|
-
return (
|
|
20
|
+
return (
|
|
21
|
+
lineHasIgnoreTag(lines[line - 1] ?? "", tag) || lineHasIgnoreTag(lines[line - 2] ?? "", tag)
|
|
22
|
+
);
|
|
6
23
|
}
|
|
7
24
|
|
|
8
25
|
/** Inline allowlist convention: `// kumiko-lint-ignore <slug> <reason>` on the
|
package/src/_lib/scan-scope.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { join, sep } from "node:path";
|
|
3
|
-
import type
|
|
3
|
+
import { type RepoKind, repoKindSchema } from "@cosmicdrift/kumiko-repo-manifest";
|
|
4
4
|
import { Glob } from "bun";
|
|
5
5
|
import type { RepoRoot } from "./roots";
|
|
6
6
|
|
|
7
|
+
// Security-shaped guards name this explicitly: the default skips "tooling" roots, and a one-word manifest kind must not switch them off.
|
|
8
|
+
export const ALL_REPO_KINDS: readonly RepoKind[] = repoKindSchema.options;
|
|
9
|
+
|
|
7
10
|
export type ScanScope = "source" | "tests";
|
|
8
11
|
export type ScanExtension = "ts" | "tsx";
|
|
9
12
|
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Real-Provider-Isolation guard allows real runs in schedule- or workflow_dispatch-only workflows",
|
|
6
|
+
"detail": "The Real-Provider-Isolation guard allows `test:real`, `e2e:real` and `KUMIKO_REAL_PROVIDERS` in CI workflows whose `on:` triggers are exclusively `schedule` and/or `workflow_dispatch`, so an app can run its real-provider suite weekly or by hand. Any other trigger next to them (`push`, `pull_request`, `pull_request_target`, `workflow_call`, ...) or a workflow without a parseable `on:` is still a violation."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.354.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Security guards scan the tooling root, direct-fetch guard-allow needs a concrete reason, types postgres peer accepts the prerelease alias",
|
|
12
|
+
"detail": "Final review batch G: guards and tooling\nThe security guards (`direct-fetch`, `direct-entity-writes`, `tenant-escalation`, `unsafe-json-parse`, `html-escape`, `no-direct-fs`, `restricted-symbols`, `admin-api`, `access-denied-test`, `open-to-all-reason`, `escape-hatch-declared`) now scan a `tooling` root too. `direct-fetch` rejects a `guard-allow` marker without a specific reason. The `direct-entity-writes` canary also blocks when table declarations exist but write resolution finds nothing. `@cosmicdrift/kumiko-types` accepts the `postgres` prerelease alias in its peer range."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.352.0",
|
|
4
16
|
"type": "improvement",
|
package/src/check-complexity.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
runStandalone,
|
|
46
46
|
type ScanSpec,
|
|
47
47
|
} from "./_lib/guard-kit";
|
|
48
|
-
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
48
|
+
import { hasIgnoreTag, lineHasIgnoreTag } from "./_lib/ignore-tag";
|
|
49
49
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
50
50
|
|
|
51
51
|
const ROOT = process.cwd();
|
|
@@ -111,7 +111,7 @@ function hasBudgetTag(fn: Node): boolean {
|
|
|
111
111
|
const jsDocStartLine = fn.getStartLineNumber(true);
|
|
112
112
|
if (jsDocStartLine === fn.getStartLineNumber()) return false;
|
|
113
113
|
const lines = fn.getSourceFile().getFullText().split("\n");
|
|
114
|
-
return (lines[jsDocStartLine - 2] ?? ""
|
|
114
|
+
return lineHasIgnoreTag(lines[jsDocStartLine - 2] ?? "", BUDGET_TAG);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
export function computeComplexity(fn: Node): number {
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
* Real-Provider-Isolation-Guard (stack-agnostisch, #3118).
|
|
4
4
|
*
|
|
5
5
|
* Real-provider specs (`*.real.test.ts`, `*.real.spec.ts`) hit live third-party
|
|
6
|
-
* services and must only ever run
|
|
6
|
+
* services and must only ever run opt-in (manually or scheduled) via `bun run test:real` /
|
|
7
7
|
* `bun run e2e:real` — never inside a default `bun test`/`playwright test`
|
|
8
|
-
* glob and never inside CI (an accidental `KUMIKO_REAL_PROVIDERS=1` in a
|
|
8
|
+
* glob and never inside push/PR-triggered CI (an accidental `KUMIKO_REAL_PROVIDERS=1` in a
|
|
9
9
|
* workflow spends real quota/credentials on every push).
|
|
10
10
|
*
|
|
11
11
|
* Flags:
|
|
12
12
|
* (a) any CI workflow (`.github/workflows/*.{yml,yaml}`) that references
|
|
13
|
-
* KUMIKO_REAL_PROVIDERS, `test:real`, or `e2e:real
|
|
14
|
-
*
|
|
13
|
+
* KUMIKO_REAL_PROVIDERS, `test:real`, or `e2e:real`, unless its `on:`
|
|
14
|
+
* triggers are exclusively `schedule` and/or `workflow_dispatch`. A
|
|
15
|
+
* scheduled or manually started real run spends quota at a known rate;
|
|
16
|
+
* a push/pull_request (or reusable `workflow_call`) trigger would spend
|
|
17
|
+
* it on every push, so those stay violations.
|
|
15
18
|
* (b) one of the template's own non-`real` bunfig files (`bunfig.toml`,
|
|
16
19
|
* `bunfig.integration.toml`, `bunfig.dom.toml` — see BUNFIG_FILES in
|
|
17
20
|
* packages/testing/src/bunfig.ts) whose pathIgnorePatterns doesn't
|
|
@@ -56,11 +59,35 @@ function ciWorkflowViolation(line: string): boolean {
|
|
|
56
59
|
);
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
const COST_BOUNDED_TRIGGERS: ReadonlySet<string> = new Set(["schedule", "workflow_dispatch"]);
|
|
63
|
+
|
|
64
|
+
function workflowTriggerNames(on: unknown): readonly string[] {
|
|
65
|
+
if (typeof on === "string") return [on];
|
|
66
|
+
if (Array.isArray(on)) return on.filter((entry): entry is string => typeof entry === "string");
|
|
67
|
+
if (on !== null && typeof on === "object") return Object.keys(on);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Unparseable YAML or a missing `on:` falls through to the line scan (fail closed).
|
|
72
|
+
function runsOnlyOnCostBoundedTriggers(workflowYaml: string): boolean {
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = Bun.YAML.parse(workflowYaml);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (parsed === null || typeof parsed !== "object" || !("on" in parsed)) return false;
|
|
80
|
+
const triggers = workflowTriggerNames(parsed.on);
|
|
81
|
+
return triggers.length > 0 && triggers.every((name) => COST_BOUNDED_TRIGGERS.has(name));
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
function scanCiWorkflows(root: RepoRoot, findings: TextLineFinding[]): number {
|
|
60
85
|
let scanned = 0;
|
|
61
86
|
for (const rel of new Glob(CI_WORKFLOW_GLOB).scanSync({ cwd: root.absPath })) {
|
|
62
87
|
scanned++;
|
|
63
|
-
|
|
88
|
+
const abs = join(root.absPath, rel);
|
|
89
|
+
if (runsOnlyOnCostBoundedTriggers(readFileSync(abs, "utf8"))) continue;
|
|
90
|
+
scanLinesForPredicate(abs, rel, ciWorkflowViolation, findings);
|
|
64
91
|
}
|
|
65
92
|
return scanned;
|
|
66
93
|
}
|
|
@@ -173,7 +200,8 @@ export const check: RepoCheck = {
|
|
|
173
200
|
name: "Real-Provider-Isolation Guard",
|
|
174
201
|
hint:
|
|
175
202
|
`Real-provider tests run only via \`bun run test:real\`/\`bun run e2e:real\`, ` +
|
|
176
|
-
`never in
|
|
203
|
+
`never in a push/pull_request-triggered CI workflow (schedule/workflow_dispatch only) ` +
|
|
204
|
+
`and never in a default test glob. Regenerate bunfig via ` +
|
|
177
205
|
`\`kumiko-testing bunfig\` instead of hand-editing pathIgnorePatterns.`,
|
|
178
206
|
async run(roots) {
|
|
179
207
|
if (roots.length === 0) {
|
|
@@ -27,6 +27,7 @@ import { join } from "node:path";
|
|
|
27
27
|
import { Glob } from "bun";
|
|
28
28
|
import { ts } from "ts-morph";
|
|
29
29
|
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
30
|
+
import { lineHasIgnoreTag } from "./_lib/ignore-tag";
|
|
30
31
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
31
32
|
|
|
32
33
|
// Server-side only: a declared sourceRoot under a mobile/client app has no server secrets to leak.
|
|
@@ -116,7 +117,8 @@ export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLite
|
|
|
116
117
|
for (let i = 0; i < lines.length; i++) {
|
|
117
118
|
const raw = lines[i] ?? "";
|
|
118
119
|
const code = maskedLines[i] ?? "";
|
|
119
|
-
if (raw
|
|
120
|
+
if (lineHasIgnoreTag(raw, IGNORE_TAG) || lineHasIgnoreTag(lines[i - 1] ?? "", IGNORE_TAG))
|
|
121
|
+
continue;
|
|
120
122
|
// Every fallback on the line is inspected; the name is read only from the text since the
|
|
121
123
|
// previous fallback, so an earlier assignee cannot lend its name to a later literal.
|
|
122
124
|
let previousEnd = 0;
|
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
21
21
|
import { findRepoRootFor } from "./_lib/baseline-compare";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
ALL_REPO_KINDS,
|
|
24
|
+
type AstGuard,
|
|
25
|
+
type GuardViolation,
|
|
26
|
+
runStandalone,
|
|
27
|
+
type ScanSpec,
|
|
28
|
+
} from "./_lib/guard-kit";
|
|
23
29
|
import {
|
|
24
30
|
escapeRegExp,
|
|
25
31
|
literalStringOf,
|
|
@@ -31,6 +37,7 @@ import { TEST_FILE_RE } from "./_lib/test-file";
|
|
|
31
37
|
|
|
32
38
|
const ROOT = process.cwd();
|
|
33
39
|
const SCAN: ScanSpec = {
|
|
40
|
+
kinds: ALL_REPO_KINDS,
|
|
34
41
|
scope: "source+tests",
|
|
35
42
|
extensions: ["ts"],
|
|
36
43
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
package/src/guard-admin-api.ts
CHANGED
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
import * as path from "node:path";
|
|
23
23
|
import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
|
|
24
24
|
import { findRepoRootFor } from "./_lib/baseline-compare";
|
|
25
|
-
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
25
|
+
import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
26
26
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
27
27
|
|
|
28
28
|
const ROOT = process.cwd();
|
|
29
29
|
|
|
30
|
-
const SCAN: ScanSpec = { scope: "source", extensions: ["ts"] };
|
|
30
|
+
const SCAN: ScanSpec = { kinds: ALL_REPO_KINDS, scope: "source", extensions: ["ts"] };
|
|
31
31
|
|
|
32
32
|
// Test files may use the API freely — they are the primary verifiers.
|
|
33
33
|
// App code is never shipped through tests.
|
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { ts } from "ts-morph";
|
|
4
4
|
import { gitEnv } from "./_lib/git-env";
|
|
5
5
|
import { cliFlagsError, isLocalFinding } from "./_lib/guard-kit";
|
|
6
|
+
import { lineHasIgnoreTag } from "./_lib/ignore-tag";
|
|
6
7
|
import { resolveRepoRoots } from "./_lib/roots";
|
|
7
8
|
import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
|
|
8
9
|
|
|
@@ -71,7 +72,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
|
|
|
71
72
|
case ts.SyntaxKind.SingleLineCommentTrivia:
|
|
72
73
|
case ts.SyntaxKind.MultiLineCommentTrivia: {
|
|
73
74
|
const text = scanner.getTokenText();
|
|
74
|
-
if (!text
|
|
75
|
+
if (!lineHasIgnoreTag(text, IGNORE_TAG) && isGermanComment(text)) {
|
|
75
76
|
const line = fullText.slice(0, scanner.getTokenStart()).split("\n").length;
|
|
76
77
|
sites.push({
|
|
77
78
|
file,
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import * as path from "node:path";
|
|
41
41
|
import type { SourceFile } from "ts-morph";
|
|
42
42
|
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
43
|
+
import { lineHasIgnoreTag } from "./_lib/ignore-tag";
|
|
43
44
|
|
|
44
45
|
const ROOT = process.cwd();
|
|
45
46
|
|
|
@@ -178,9 +179,9 @@ function isIgnored(sf: SourceFile, importLine: number): boolean {
|
|
|
178
179
|
const text = sf.getFullText();
|
|
179
180
|
const lines = text.split("\n");
|
|
180
181
|
const onLine = lines[importLine - 1] ?? "";
|
|
181
|
-
if (onLine
|
|
182
|
+
if (lineHasIgnoreTag(onLine, IGNORE_TAG)) return true;
|
|
182
183
|
const above = lines[importLine - 2] ?? "";
|
|
183
|
-
return above.trim().startsWith("//") && above
|
|
184
|
+
return above.trim().startsWith("//") && lineHasIgnoreTag(above, IGNORE_TAG);
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
function findCrossFeatureViolations(sf: SourceFile): Omit<Violation, "file">[] {
|
|
@@ -51,11 +51,12 @@ import {
|
|
|
51
51
|
type SourceFile,
|
|
52
52
|
SyntaxKind,
|
|
53
53
|
} from "ts-morph";
|
|
54
|
-
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
54
|
+
import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
55
55
|
|
|
56
56
|
const ROOT = process.cwd();
|
|
57
57
|
|
|
58
58
|
const SCAN: ScanSpec = {
|
|
59
|
+
kinds: ALL_REPO_KINDS,
|
|
59
60
|
scope: "source",
|
|
60
61
|
extensions: ["ts"],
|
|
61
62
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
@@ -323,11 +324,23 @@ function isTableWriteCall(call: CallExpression): boolean {
|
|
|
323
324
|
);
|
|
324
325
|
}
|
|
325
326
|
|
|
326
|
-
function
|
|
327
|
+
function isTableBuilderCall(call: CallExpression): boolean {
|
|
328
|
+
const callee = call.getExpression();
|
|
329
|
+
const calleeName =
|
|
330
|
+
callee.getKind() === SyntaxKind.PropertyAccessExpression
|
|
331
|
+
? callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName()
|
|
332
|
+
: callee.getText();
|
|
333
|
+
return TABLE_BUILDER_NAMES.has(calleeName);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Table declarations count as evidence too: when write resolution breaks
|
|
337
|
+
// structurally (`tables.userTable`, re-exports) no write is recognised, but the
|
|
338
|
+
// declarations are still visible, so the empty-set canary must not go green.
|
|
339
|
+
function hasAnyTableWriteOrDeclaration(files: readonly SourceFile[]): boolean {
|
|
327
340
|
for (const sf of files) {
|
|
328
341
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
329
342
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
330
|
-
if (isTableWriteCall(call)) return true;
|
|
343
|
+
if (isTableWriteCall(call) || isTableBuilderCall(call)) return true;
|
|
331
344
|
}
|
|
332
345
|
}
|
|
333
346
|
return false;
|
|
@@ -399,14 +412,14 @@ export const guard: AstGuard = {
|
|
|
399
412
|
if (esTables.size === 0) {
|
|
400
413
|
// An empty set means either the repo has no event store at all, or the
|
|
401
414
|
// scan lost the ES definitions while table writes are still present.
|
|
402
|
-
if (!
|
|
415
|
+
if (!hasAnyTableWriteOrDeclaration(files)) return { violations: [] };
|
|
403
416
|
return {
|
|
404
417
|
violations: [
|
|
405
418
|
{
|
|
406
419
|
file: "<scan>",
|
|
407
420
|
line: 0,
|
|
408
421
|
message:
|
|
409
|
-
"BLOCKED: guard found table writes but no createEventStoreExecutor or r.entity projection tables — scan is probably misconfigured.",
|
|
422
|
+
"BLOCKED: guard found table writes or table declarations but no createEventStoreExecutor or r.entity projection tables — scan is probably misconfigured.",
|
|
410
423
|
},
|
|
411
424
|
],
|
|
412
425
|
};
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import { relative as pathRelative } from "node:path";
|
|
23
23
|
import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
24
|
-
import { literalReasonText } from "./_lib/generic-reason";
|
|
24
|
+
import { isGenericReason, literalReasonText } from "./_lib/generic-reason";
|
|
25
25
|
import {
|
|
26
|
+
ALL_REPO_KINDS,
|
|
26
27
|
type AstGuard,
|
|
27
28
|
findRepoRootFor,
|
|
28
29
|
isAllowlisted,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
34
35
|
|
|
35
36
|
const SCAN: ScanSpec = {
|
|
37
|
+
kinds: ALL_REPO_KINDS,
|
|
36
38
|
scope: "source",
|
|
37
39
|
extensions: ["ts"],
|
|
38
40
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
@@ -57,12 +59,20 @@ interface Violation {
|
|
|
57
59
|
// `const self = this` binding was a systematic FP (infra#582).
|
|
58
60
|
const GLOBAL_FETCH_RECEIVERS = new Set(["globalThis", "window"]);
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
// The marker is a per-line skip for a security guard, so it only counts with a
|
|
63
|
+
// non-generic reason after it — a bare marker still reports the finding.
|
|
64
|
+
function hasAllowMarkerWithReason(sf: SourceFile, line: number): boolean {
|
|
61
65
|
const lines = sf.getFullText().split("\n");
|
|
62
66
|
const idx = line - 1;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
return [lines[idx] ?? "", lines[idx - 1] ?? ""].some((text) => {
|
|
68
|
+
const markerAt = text.indexOf(ALLOW_MARKER);
|
|
69
|
+
if (markerAt < 0) return false;
|
|
70
|
+
const reason = text
|
|
71
|
+
.slice(markerAt + ALLOW_MARKER.length)
|
|
72
|
+
.replace(/^\s*(?:—|–|-|:)\s*/, "")
|
|
73
|
+
.trim();
|
|
74
|
+
return !isGenericReason(reason);
|
|
75
|
+
});
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/** Same-origin path literal (`"/api/..."`, '`/demo`') — not an SSRF risk. */
|
|
@@ -117,7 +127,7 @@ export const guard: AstGuard = {
|
|
|
117
127
|
security: true,
|
|
118
128
|
hint:
|
|
119
129
|
"Replace raw fetch(...) with egress(policy)(...) from @cosmicdrift/kumiko-framework/http (framework#2147). " +
|
|
120
|
-
'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// guard-allow: same-origin fetch
|
|
130
|
+
'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// guard-allow: same-origin fetch — <specific reason>` on the line above (a bare marker does not count). ' +
|
|
121
131
|
"Local bindings named fetch are flagged (false positive — unblock via that marker). " +
|
|
122
132
|
'Known gap: bracket access (globalThis["fetch"](...)).',
|
|
123
133
|
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
@@ -132,7 +142,7 @@ export const guard: AstGuard = {
|
|
|
132
142
|
root === undefined ? undefined : `${root.name}/${pathRelative(root.absPath, file)}`;
|
|
133
143
|
if (key !== undefined && isAllowlisted(key, ALLOWLIST)) continue;
|
|
134
144
|
for (const v of findRawFetchCalls(sf)) {
|
|
135
|
-
if (
|
|
145
|
+
if (hasAllowMarkerWithReason(sf, v.line)) continue;
|
|
136
146
|
violations.push({
|
|
137
147
|
// cwd-relative, not repo-relative `rel` — the security baseline
|
|
138
148
|
// needs an unambiguous path to resolve back to (repo, relPath).
|
|
@@ -82,9 +82,16 @@ import {
|
|
|
82
82
|
} from "ts-morph";
|
|
83
83
|
import { isFrameworkImportOf } from "./_lib/framework-import";
|
|
84
84
|
import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
|
|
85
|
-
import {
|
|
85
|
+
import {
|
|
86
|
+
ALL_REPO_KINDS,
|
|
87
|
+
type AstGuard,
|
|
88
|
+
type GuardViolation,
|
|
89
|
+
runStandalone,
|
|
90
|
+
type ScanSpec,
|
|
91
|
+
} from "./_lib/guard-kit";
|
|
86
92
|
|
|
87
93
|
const SCAN: ScanSpec = {
|
|
94
|
+
kinds: ALL_REPO_KINDS,
|
|
88
95
|
scope: "source",
|
|
89
96
|
extensions: ["ts"],
|
|
90
97
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
package/src/guard-html-escape.ts
CHANGED
|
@@ -42,11 +42,12 @@ import {
|
|
|
42
42
|
type TemplateExpression,
|
|
43
43
|
type Type,
|
|
44
44
|
} from "ts-morph";
|
|
45
|
-
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
45
|
+
import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
46
46
|
|
|
47
47
|
const ROOT = process.cwd();
|
|
48
48
|
|
|
49
49
|
const SCAN: ScanSpec = {
|
|
50
|
+
kinds: ALL_REPO_KINDS,
|
|
50
51
|
scope: "source",
|
|
51
52
|
extensions: ["ts", "tsx"],
|
|
52
53
|
frameworkWithin: ["packages/*/src/**"],
|
|
@@ -38,6 +38,7 @@ import { existsSync } from "node:fs";
|
|
|
38
38
|
import { join, relative as pathRelative } from "node:path";
|
|
39
39
|
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
40
40
|
import {
|
|
41
|
+
ALL_REPO_KINDS,
|
|
41
42
|
type AstGuard,
|
|
42
43
|
baselineRatchet,
|
|
43
44
|
findRepoRootFor,
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
50
51
|
|
|
51
52
|
const SCAN: ScanSpec = {
|
|
53
|
+
kinds: ALL_REPO_KINDS,
|
|
52
54
|
scope: "source",
|
|
53
55
|
extensions: ["ts"],
|
|
54
56
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
@@ -17,9 +17,16 @@
|
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
19
19
|
import { isGenericReason, literalReasonText } from "./_lib/generic-reason";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
ALL_REPO_KINDS,
|
|
22
|
+
type AstGuard,
|
|
23
|
+
type GuardViolation,
|
|
24
|
+
runStandalone,
|
|
25
|
+
type ScanSpec,
|
|
26
|
+
} from "./_lib/guard-kit";
|
|
21
27
|
|
|
22
28
|
const SCAN: ScanSpec = {
|
|
29
|
+
kinds: ALL_REPO_KINDS,
|
|
23
30
|
scope: "source",
|
|
24
31
|
extensions: ["ts"],
|
|
25
32
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
import * as fs from "node:fs";
|
|
40
40
|
import * as path from "node:path";
|
|
41
41
|
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
42
|
+
import { lineHasIgnoreTag } from "./_lib/ignore-tag";
|
|
42
43
|
import { isFlatSrcLayout, type RepoRoot, sourceRootDirs } from "./_lib/roots";
|
|
43
44
|
|
|
44
45
|
const FORBIDDEN_TAGS: ReadonlyArray<{
|
|
@@ -201,7 +202,7 @@ function isPublicPage(file: string): boolean {
|
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
function hasIgnore(currentLine: string, prevLine: string): boolean {
|
|
204
|
-
return currentLine
|
|
205
|
+
return lineHasIgnoreTag(currentLine, IGNORE_TAG) || lineHasIgnoreTag(prevLine, IGNORE_TAG);
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
export function checkFile(
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
19
19
|
import {
|
|
20
|
+
ALL_REPO_KINDS,
|
|
20
21
|
type AstGuard,
|
|
21
22
|
isAllowlisted,
|
|
22
23
|
relFromRepoRoot,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
import { resolveRepoRoots } from "./_lib/roots";
|
|
27
28
|
|
|
28
29
|
const SCAN: ScanSpec = {
|
|
30
|
+
kinds: ALL_REPO_KINDS,
|
|
29
31
|
scope: "source",
|
|
30
32
|
extensions: ["ts"],
|
|
31
33
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
@@ -45,13 +45,14 @@
|
|
|
45
45
|
|
|
46
46
|
import * as path from "node:path";
|
|
47
47
|
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
48
|
-
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
48
|
+
import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
49
49
|
import { literalStringOf, mentionsAsWord, nameForms } from "./_lib/handler-name-forms";
|
|
50
50
|
import { TEST_FILE_RE } from "./_lib/test-file";
|
|
51
51
|
|
|
52
52
|
const ROOT = process.cwd();
|
|
53
53
|
|
|
54
54
|
const SCAN: ScanSpec = {
|
|
55
|
+
kinds: ALL_REPO_KINDS,
|
|
55
56
|
scope: "source+tests",
|
|
56
57
|
extensions: ["ts"],
|
|
57
58
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
14
|
-
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
14
|
+
import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
15
15
|
|
|
16
16
|
const ROOT = process.cwd();
|
|
17
17
|
|
|
18
18
|
const SCAN: ScanSpec = {
|
|
19
|
+
kinds: ALL_REPO_KINDS,
|
|
19
20
|
scope: "source",
|
|
20
21
|
extensions: ["ts"],
|
|
21
22
|
frameworkWithin: ["packages/*/src/**"],
|