@cosmicdrift/kumiko-guards 0.331.0 → 0.332.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/git-env.ts +27 -1
- package/src/_lib/guard-kit.ts +3 -2
- package/src/_lib/scan-scope.ts +14 -4
- package/src/_lib/security-baseline.ts +6 -2
- package/src/_lib/test-file.ts +2 -0
- package/src/changes.json +44 -0
- package/src/check-real-provider-isolation.ts +45 -11
- package/src/check-secret-literals.ts +95 -20
- package/src/guard-access-denied-test.ts +4 -4
- package/src/guard-admin-api.ts +11 -5
- package/src/guard-direct-entity-writes.ts +37 -1
- package/src/guard-direct-fetch.ts +4 -7
- package/src/guard-escape-hatch-declared.ts +2 -1
- package/src/guard-feature-integration-tests.ts +10 -9
- package/src/guard-html-escape.ts +76 -19
- package/src/guard-lib-test-coverage.ts +10 -3
- package/src/guard-loadall-events.ts +8 -2
- package/src/guard-no-direct-process-env.ts +2 -1
- package/src/guard-no-framed-extension-sections.ts +5 -1
- package/src/guard-pii-annotations.ts +5 -4
- package/src/guard-primitives-discipline.ts +19 -18
- package/src/guard-raw-interactive-elements.ts +25 -15
- package/src/guard-tailwind-scan-surface.ts +39 -28
- package/src/guard-tenant-escalation.ts +7 -8
- package/src/guard-test-stack-drift.ts +15 -2
- package/src/guard-text-field-stance.ts +4 -3
- package/src/guard-thin-wrappers.ts +2 -3
- package/src/index.ts +1 -0
- package/src/pre-push.sh +26 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.332.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.332.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/_lib/git-env.ts
CHANGED
|
@@ -10,11 +10,37 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export const GIT_ENV_KEYS = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "USER", "LOGNAME"] as const;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Extra keys for git calls that talk to a remote (`git fetch`): without the
|
|
15
|
+
* SSH agent / proxy settings the transport cannot authenticate or connect.
|
|
16
|
+
* Opt-in via `gitEnv(env, { transport: true })`; local-only calls stay narrow.
|
|
17
|
+
* `GIT_DIR`/`GIT_WORK_TREE` are deliberately never part of this list.
|
|
18
|
+
*/
|
|
19
|
+
export const GIT_TRANSPORT_ENV_KEYS = [
|
|
20
|
+
"SSH_AUTH_SOCK",
|
|
21
|
+
"SSH_AGENT_PID",
|
|
22
|
+
"GIT_SSH",
|
|
23
|
+
"GIT_SSH_COMMAND",
|
|
24
|
+
"GIT_ASKPASS",
|
|
25
|
+
"SSH_ASKPASS",
|
|
26
|
+
"GIT_TERMINAL_PROMPT",
|
|
27
|
+
"GIT_SSL_CAINFO",
|
|
28
|
+
"HTTP_PROXY",
|
|
29
|
+
"HTTPS_PROXY",
|
|
30
|
+
"http_proxy",
|
|
31
|
+
"https_proxy",
|
|
32
|
+
"NO_PROXY",
|
|
33
|
+
"no_proxy",
|
|
34
|
+
"ALL_PROXY",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
13
37
|
export function gitEnv(
|
|
14
38
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
39
|
+
options: { readonly transport?: boolean } = {},
|
|
15
40
|
): Record<string, string> {
|
|
41
|
+
const keys = options.transport ? [...GIT_ENV_KEYS, ...GIT_TRANSPORT_ENV_KEYS] : GIT_ENV_KEYS;
|
|
16
42
|
const out: Record<string, string> = {};
|
|
17
|
-
for (const key of
|
|
43
|
+
for (const key of keys) {
|
|
18
44
|
const value = env[key];
|
|
19
45
|
if (value !== undefined) out[key] = value;
|
|
20
46
|
}
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -271,12 +271,12 @@ export type RunResult = {
|
|
|
271
271
|
readonly warnings?: readonly GuardViolation[];
|
|
272
272
|
};
|
|
273
273
|
|
|
274
|
-
// Per-root floor: a root whose declared sourceRoots hold zero .ts/.tsx files is a violation;
|
|
274
|
+
// Per-root floor: a root whose declared sourceRoots hold zero .ts/.tsx files is a violation; applies to every scope that includes source ("source", "source+tests").
|
|
275
275
|
export function checkRootFloor(
|
|
276
276
|
guard: Pick<AstGuard, "scan">,
|
|
277
277
|
scans: readonly RootScan[],
|
|
278
278
|
): { readonly violatingRoots: readonly string[] } {
|
|
279
|
-
if (guard.scan.scope
|
|
279
|
+
if (guard.scan.scope === "tests") return { violatingRoots: [] };
|
|
280
280
|
return {
|
|
281
281
|
violatingRoots: scans.filter((scan) => scan.sourceSurface === 0).map((scan) => scan.root.name),
|
|
282
282
|
};
|
|
@@ -531,6 +531,7 @@ export function printGuardKitBanner(
|
|
|
531
531
|
if (error !== undefined) {
|
|
532
532
|
console.error(error);
|
|
533
533
|
process.exit(1);
|
|
534
|
+
return;
|
|
534
535
|
}
|
|
535
536
|
console.log(`kumiko-guards ${guardKitVersion()} - ${guardCount} guards registered`);
|
|
536
537
|
console.log(`Roots: ${roots.map((r) => r.root.name).join(", ")} (${roots.length})`);
|
package/src/_lib/scan-scope.ts
CHANGED
|
@@ -24,7 +24,9 @@ export type ScanSpec =
|
|
|
24
24
|
/** Globs relative to the matched source root dir; narrows only. */
|
|
25
25
|
readonly within?: readonly string[];
|
|
26
26
|
})
|
|
27
|
-
| (ScanSpecBase & { readonly scope: "tests" })
|
|
27
|
+
| (ScanSpecBase & { readonly scope: "tests" })
|
|
28
|
+
// Union of the source and test surface — for guards that correlate source declarations with test coverage.
|
|
29
|
+
| (ScanSpecBase & { readonly scope: "source+tests" });
|
|
28
30
|
|
|
29
31
|
export type RootScan = {
|
|
30
32
|
readonly root: RepoRoot;
|
|
@@ -141,13 +143,21 @@ function keepForSpec(hit: Hit, root: RepoRoot, spec: ScanSpec): boolean {
|
|
|
141
143
|
|
|
142
144
|
function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
|
|
143
145
|
const sourceSurfaceHits = afterExcludes(sourceHits(root), root.manifest.excludes);
|
|
146
|
+
const testSurfaceHits = () => afterExcludes(testHits(root), root.manifest.excludes);
|
|
144
147
|
const scopeHits =
|
|
145
148
|
spec.scope === "source"
|
|
146
149
|
? sourceSurfaceHits
|
|
147
|
-
:
|
|
148
|
-
|
|
150
|
+
: spec.scope === "tests"
|
|
151
|
+
? testSurfaceHits()
|
|
152
|
+
: [...sourceSurfaceHits, ...testSurfaceHits()];
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
const uniqueScopeHits = scopeHits.filter((hit) => {
|
|
155
|
+
if (seen.has(hit.repoRel)) return false;
|
|
156
|
+
seen.add(hit.repoRel);
|
|
157
|
+
return true;
|
|
158
|
+
});
|
|
149
159
|
const additionalHits = afterExcludes(extraHits(root, spec.extraGlobs), root.manifest.excludes);
|
|
150
|
-
const files = [...
|
|
160
|
+
const files = [...uniqueScopeHits, ...additionalHits.filter((hit) => !seen.has(hit.repoRel))]
|
|
151
161
|
.filter((hit) => keepForSpec(hit, root, spec))
|
|
152
162
|
.map((hit) => join(root.absPath, hit.repoRel))
|
|
153
163
|
.sort();
|
|
@@ -86,10 +86,14 @@ export function parseSecurityBaseline(raw: unknown): SecurityBaseline | undefine
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function loadSecurityBaseline(repo: string, repoDir: string): SecurityBaselineLoad {
|
|
89
|
+
const file = securityBaselinePath(repoDir);
|
|
89
90
|
if (!REPO_NAME_RE.test(repo)) {
|
|
90
|
-
|
|
91
|
+
return {
|
|
92
|
+
kind: "invalid",
|
|
93
|
+
file,
|
|
94
|
+
reason: `repo name "${repo}" from package.json is not a valid lowercase package name`,
|
|
95
|
+
};
|
|
91
96
|
}
|
|
92
|
-
const file = securityBaselinePath(repoDir);
|
|
93
97
|
if (!existsSync(file)) return { kind: "ok", findings: {}, hardFail: [] };
|
|
94
98
|
let raw: unknown;
|
|
95
99
|
try {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.332.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "direct-entity-writes canary counts only real table writes"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"version": "0.332.0",
|
|
9
|
+
"type": "improvement",
|
|
10
|
+
"title": "gitEnv is exported from the package root"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"version": "0.332.0",
|
|
14
|
+
"type": "fix",
|
|
15
|
+
"title": "git transport env is kept for the changes-json fetch"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "0.332.0",
|
|
19
|
+
"type": "improvement",
|
|
20
|
+
"title": "guards resolve aliased imports, literal values, tsx lib files and entityEdit slots precisely",
|
|
21
|
+
"migration": "Neue Violations moeglich: lib-test-coverage erfasst jetzt .tsx und tiefere lib-Ebenen und zaehlt Fixtures unter __tests__ ohne .test-Suffix nicht als Test; test-stack-drift loest Import-Aliase auf; direct-fetch wertet den Laufzeitwert des Literals aus; no-framed-extension-sections behandelt nur slots.header von entityEdit-Screens als Header. Betroffene Stellen mit Test, echtem Import oder Anpassung beheben."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"version": "0.332.0",
|
|
25
|
+
"type": "improvement",
|
|
26
|
+
"title": "Coverage guards scan test files; admin-api, primitives-discipline and real-provider-isolation resolve roots and workspaces properly",
|
|
27
|
+
"migration": "Neue Violations moeglich: access-denied-test und tenant-escalation zaehlen jetzt auch *.integration.ts und Tests ausserhalb der sourceRoots als Coverage (Scope source+tests); primitives-discipline scannt alle flat-src-App-Roots; check-real-provider-isolation flaggt Scripts, die test:real/e2e:real aufrufen, und prueft alle Workspace-package.json. Betroffene Stellen fixen oder per Security-Baseline neu einfrieren."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"version": "0.332.0",
|
|
31
|
+
"type": "improvement",
|
|
32
|
+
"title": "secret-literal guard flags more fallbacks and no longer leaks literals",
|
|
33
|
+
"migration": "Neue Violations bei `*/ code`-Zeilen, Code-Zeilen mit fuehrendem `*` und Literalen mit hmac/private-key/signing-key; die Violation-Message nennt Name und Laenge statt der Zeile. Fallbacks entfernen und das Secret hart aus dem validierten Env lesen."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"version": "0.332.0",
|
|
37
|
+
"type": "improvement",
|
|
38
|
+
"title": "html-escape guard flags unescaped intermediate templates and raw-returning local helpers",
|
|
39
|
+
"migration": "Neue Violations bei ungeescapten Zwischen-Templates und lokalen Helfern, die Input roh zurueckgeben; diese per escapeHtml absichern."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"version": "0.332.0",
|
|
43
|
+
"type": "improvement",
|
|
44
|
+
"title": "worktree pre-push runs the consumer-CI guards"
|
|
45
|
+
},
|
|
2
46
|
{
|
|
3
47
|
"version": "0.331.0",
|
|
4
48
|
"type": "improvement",
|
|
@@ -68,32 +68,66 @@ function scanCiWorkflows(root: RepoRoot, findings: TextLineFinding[]): number {
|
|
|
68
68
|
// package.json scripts: a real-provider signal in any script OTHER than the
|
|
69
69
|
// two designated ones is a leak (e.g. a "test" or "ci" script that pulls in
|
|
70
70
|
// the real env or filters for a .real. spec by hand).
|
|
71
|
+
const REAL_SCRIPT_CALL = /\b(?:test|e2e):real\b/;
|
|
72
|
+
|
|
71
73
|
function scriptLeaksRealProvider(name: string, command: string): string | undefined {
|
|
72
74
|
if (REAL_SCRIPT_NAMES.has(name)) return undefined;
|
|
73
75
|
if (command.includes(REAL_PROVIDERS_ENV)) {
|
|
74
76
|
return `script "${name}" sets ${REAL_PROVIDERS_ENV} outside test:real/e2e:real: ${command}`;
|
|
75
77
|
}
|
|
78
|
+
if (REAL_SCRIPT_CALL.test(command)) {
|
|
79
|
+
return `script "${name}" invokes test:real/e2e:real, which would run real-provider specs from a default script: ${command}`;
|
|
80
|
+
}
|
|
76
81
|
if (/\.real\.(test|spec)\.ts/.test(command)) {
|
|
77
82
|
return `script "${name}" references a *.real. spec/test outside test:real/e2e:real: ${command}`;
|
|
78
83
|
}
|
|
79
84
|
return undefined;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
type WorkspaceManifest = {
|
|
88
|
+
scripts?: Record<string, string>;
|
|
89
|
+
workspaces?: string[] | { packages?: string[] };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function readManifest(abs: string): WorkspaceManifest | undefined {
|
|
85
93
|
try {
|
|
86
|
-
|
|
94
|
+
return JSON.parse(readFileSync(abs, "utf8")) as WorkspaceManifest;
|
|
87
95
|
} catch {
|
|
88
|
-
return
|
|
96
|
+
return undefined;
|
|
89
97
|
}
|
|
90
|
-
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function workspacePatterns(manifest: WorkspaceManifest): string[] {
|
|
101
|
+
const declared = Array.isArray(manifest.workspaces)
|
|
102
|
+
? manifest.workspaces
|
|
103
|
+
: (manifest.workspaces?.packages ?? []);
|
|
104
|
+
return declared.filter((pattern) => !pattern.startsWith("!"));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Root package.json plus every workspace package.json the root's `workspaces` globs resolve to.
|
|
108
|
+
function packageJsonRels(root: RepoRoot, rootManifest: WorkspaceManifest): string[] {
|
|
109
|
+
const rels = new Set<string>(["package.json"]);
|
|
110
|
+
for (const pattern of workspacePatterns(rootManifest)) {
|
|
111
|
+
const glob = new Glob(`${pattern.replace(/\/$/, "")}/package.json`);
|
|
112
|
+
for (const rel of glob.scanSync({ cwd: root.absPath })) {
|
|
113
|
+
if (rel.split("/").includes("node_modules")) continue;
|
|
114
|
+
rels.add(rel);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...rels].sort();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scanPackageJsonScripts(root: RepoRoot, findings: TextLineFinding[]): number {
|
|
121
|
+
const rootManifest = readManifest(join(root.absPath, "package.json"));
|
|
122
|
+
if (rootManifest === undefined) return 0;
|
|
91
123
|
let scanned = 0;
|
|
92
|
-
for (const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
for (const rel of packageJsonRels(root, rootManifest)) {
|
|
125
|
+
const manifest = rel === "package.json" ? rootManifest : readManifest(join(root.absPath, rel));
|
|
126
|
+
if (manifest === undefined) continue;
|
|
127
|
+
for (const [name, command] of Object.entries(manifest.scripts ?? {})) {
|
|
128
|
+
scanned++;
|
|
129
|
+
const message = scriptLeaksRealProvider(name, command);
|
|
130
|
+
if (message !== undefined) findings.push({ file: rel, line: 1, text: message });
|
|
97
131
|
}
|
|
98
132
|
}
|
|
99
133
|
return scanned;
|
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
* jedes Repo läuft diesen Check in seiner eigenen CI gegen sich selbst,
|
|
23
23
|
* statt dass ein zentraler Scan in fremde Sibling-Checkouts greift.
|
|
24
24
|
*/
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
25
26
|
import { join } from "node:path";
|
|
26
27
|
import { Glob } from "bun";
|
|
28
|
+
import { ts } from "ts-morph";
|
|
27
29
|
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
28
30
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
29
|
-
import { scanLinesForPredicate } from "./_lib/scan-lines";
|
|
30
31
|
|
|
31
32
|
// Server-side only: a declared sourceRoot under a mobile/client app has no server secrets to leak.
|
|
32
33
|
const CLIENT_SOURCE_ROOT = /(?:^|\/)mobile(?:\/|$)/;
|
|
@@ -42,31 +43,94 @@ const DEV_ENTRYPOINT = /(?:^|\/)bin\/server\.ts$/;
|
|
|
42
43
|
|
|
43
44
|
// A `?? "literal"` / `|| "literal"` nullish/or fallback to a string literal.
|
|
44
45
|
const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/;
|
|
45
|
-
//
|
|
46
|
-
|
|
46
|
+
// A secret-like identifier. Matched only against the text LEFT of the fallback
|
|
47
|
+
// (assignee / property name): `cfg.title ?? "Secret Santa"` is a label, not a secret.
|
|
48
|
+
const SECRET_NAME =
|
|
49
|
+
/[\w$]*(?:secret|password|passphrase|hmac|private[_-]?key|signing[_-]?key)[\w$]*/i;
|
|
47
50
|
// Short / numeric literals are versions or flags (e.g. `_CURRENT_VERSION ?? "1"`),
|
|
48
51
|
// never a usable secret.
|
|
49
52
|
const TRIVIAL_LITERAL = /^[\d.]+$/;
|
|
53
|
+
const MIN_SECRET_LENGTH = 8;
|
|
54
|
+
const IGNORE_TAG = "kumiko-lint-ignore secret-literal";
|
|
50
55
|
|
|
51
56
|
export type SecretLiteralFinding = {
|
|
52
57
|
readonly file: string;
|
|
53
58
|
readonly line: number;
|
|
54
|
-
|
|
59
|
+
/** Assignee / property name left of the fallback. Never the literal. */
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly literalLength: number;
|
|
55
62
|
};
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
const isJsDocKind = (kind: ts.SyntaxKind): boolean =>
|
|
65
|
+
kind >= ts.SyntaxKind.FirstJSDocNode && kind <= ts.SyntaxKind.LastJSDocNode;
|
|
66
|
+
|
|
67
|
+
// Blanks every comment with spaces (newlines kept, so line numbers survive).
|
|
68
|
+
// The real TypeScript parser decides what is a comment: a hand-rolled
|
|
69
|
+
// per-line tokenizer misreads regex literals (`/^https?:\/*/`) and template
|
|
70
|
+
// continuation lines as comment starts and then skips code, which would make
|
|
71
|
+
// this security guard fail open. Trivia between two tokens contains only
|
|
72
|
+
// whitespace and comments, so it is masked directly from the token gaps.
|
|
73
|
+
function maskComments(source: string): string {
|
|
74
|
+
const sourceFile = ts.createSourceFile("scan.ts", source, ts.ScriptTarget.Latest, false);
|
|
75
|
+
const chars = [...source];
|
|
76
|
+
const blank = (from: number, to: number): void => {
|
|
77
|
+
for (let i = from; i < to; i++) if (chars[i] !== "\n") chars[i] = " ";
|
|
78
|
+
};
|
|
79
|
+
const visit = (node: ts.Node): void => {
|
|
80
|
+
const children = node.getChildren(sourceFile).filter((child) => !isJsDocKind(child.kind));
|
|
81
|
+
if (children.length > 0) {
|
|
82
|
+
for (const child of children) visit(child);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const trivia = source.slice(node.pos, node.getStart(sourceFile));
|
|
86
|
+
for (const m of trivia.matchAll(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g)) {
|
|
87
|
+
blank(node.pos + m.index, node.pos + m.index + m[0].length);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
visit(sourceFile);
|
|
91
|
+
return chars.join("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type SecretLiteralHit = {
|
|
95
|
+
readonly lineNumber: number;
|
|
96
|
+
readonly name: string;
|
|
97
|
+
readonly literalLength: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Literal content alone is enough when it names key material; bare
|
|
101
|
+
// secret/password stays name-only so "Secret Santa" labels are not flagged.
|
|
102
|
+
const SECRET_LITERAL_TOKEN = /hmac|private[_-]?key|signing[_-]?key/i;
|
|
103
|
+
// Assignee / property name directly left of the fallback (`k = …`, `key: …`).
|
|
104
|
+
const LHS_NAME = /([\w$]+)\s*[:=][^:=]*$/;
|
|
105
|
+
const UNNAMED_PLACEHOLDER = "<unnamed>";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Per-file scan on comment-masked source (see maskComments), so only comment
|
|
109
|
+
* text is skipped and a wrapped code line that starts with `*` is still
|
|
110
|
+
* checked. Opt-out: `// kumiko-lint-ignore secret-literal <reason>` on the
|
|
111
|
+
* line itself or the line above (checked on the raw lines).
|
|
112
|
+
*/
|
|
113
|
+
export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLiteralHit[] {
|
|
114
|
+
const hits: SecretLiteralHit[] = [];
|
|
115
|
+
const maskedLines = maskComments(lines.join("\n")).split("\n");
|
|
116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
117
|
+
const raw = lines[i] ?? "";
|
|
118
|
+
const code = maskedLines[i] ?? "";
|
|
119
|
+
const match = STRING_FALLBACK.exec(code);
|
|
120
|
+
if (!match) continue;
|
|
121
|
+
const literal = match[2];
|
|
122
|
+
if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
|
|
123
|
+
const left = code.slice(0, match.index);
|
|
124
|
+
const name =
|
|
125
|
+
SECRET_NAME.exec(left)?.[0] ??
|
|
126
|
+
(SECRET_LITERAL_TOKEN.test(literal)
|
|
127
|
+
? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
|
|
128
|
+
: undefined);
|
|
129
|
+
if (name === undefined) continue;
|
|
130
|
+
if (raw.includes(IGNORE_TAG) || (lines[i - 1] ?? "").includes(IGNORE_TAG)) continue;
|
|
131
|
+
hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
|
|
132
|
+
}
|
|
133
|
+
return hits;
|
|
70
134
|
}
|
|
71
135
|
|
|
72
136
|
async function scanRoot(
|
|
@@ -79,9 +143,16 @@ async function scanRoot(
|
|
|
79
143
|
if (EXCLUDE_DIR.test(`/${rel}`) || IS_TEST.test(rel) || DEV_ENTRYPOINT.test(rel)) {
|
|
80
144
|
continue;
|
|
81
145
|
}
|
|
82
|
-
const abs = join(root.absPath, rel);
|
|
83
146
|
scannedFiles++;
|
|
84
|
-
|
|
147
|
+
const lines = readFileSync(join(root.absPath, rel), "utf8").split("\n");
|
|
148
|
+
for (const hit of scanLinesForSecretLiterals(lines)) {
|
|
149
|
+
findings.push({
|
|
150
|
+
file: rel,
|
|
151
|
+
line: hit.lineNumber,
|
|
152
|
+
name: hit.name,
|
|
153
|
+
literalLength: hit.literalLength,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
85
156
|
}
|
|
86
157
|
}
|
|
87
158
|
return { findings, scannedFiles };
|
|
@@ -112,7 +183,11 @@ export const check: RepoCheck = {
|
|
|
112
183
|
}
|
|
113
184
|
const { findings, scannedFiles } = await scanSecretLiterals(roots);
|
|
114
185
|
return {
|
|
115
|
-
violations: findings.map((f) => ({
|
|
186
|
+
violations: findings.map((f) => ({
|
|
187
|
+
file: f.file,
|
|
188
|
+
line: f.line,
|
|
189
|
+
message: `hardcoded secret fallback for "${f.name}" (${f.literalLength} chars)`,
|
|
190
|
+
})),
|
|
116
191
|
matchedFiles: scannedFiles,
|
|
117
192
|
notApplicable: false,
|
|
118
193
|
};
|
|
@@ -27,14 +27,14 @@ import {
|
|
|
27
27
|
nameForms,
|
|
28
28
|
} from "./_lib/handler-name-forms";
|
|
29
29
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
30
|
+
import { TEST_FILE_RE } from "./_lib/test-file";
|
|
30
31
|
|
|
31
32
|
const ROOT = process.cwd();
|
|
32
33
|
const SCAN: ScanSpec = {
|
|
33
|
-
scope: "source",
|
|
34
|
+
scope: "source+tests",
|
|
34
35
|
extensions: ["ts"],
|
|
35
36
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
36
37
|
};
|
|
37
|
-
const TEST_FILE = /\.test\.ts$/;
|
|
38
38
|
|
|
39
39
|
// Any role that lets an unauthenticated/unrestricted caller through means
|
|
40
40
|
// nobody is actually excluded by this handler's roles.
|
|
@@ -47,7 +47,7 @@ export function findRoleRestrictedWriteHandlers(
|
|
|
47
47
|
): RoleRestrictedHandler[] {
|
|
48
48
|
const out: RoleRestrictedHandler[] = [];
|
|
49
49
|
for (const sf of files) {
|
|
50
|
-
if (
|
|
50
|
+
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
51
51
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
52
52
|
const callee = call.getExpression().getText();
|
|
53
53
|
if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) continue;
|
|
@@ -99,7 +99,7 @@ export function findHandlersWithoutAccessDeniedTest(
|
|
|
99
99
|
const repoKeyOf = (filePath: string): string => findRepoRootFor(filePath, roots)?.absPath ?? "";
|
|
100
100
|
const testTextsByRepo = new Map<string, string[]>();
|
|
101
101
|
for (const sf of files) {
|
|
102
|
-
if (!
|
|
102
|
+
if (!TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
103
103
|
const text = sf.getFullText();
|
|
104
104
|
if (!ACCESS_DENIED_ASSERTION.test(text)) continue;
|
|
105
105
|
const repoKey = repoKeyOf(sf.getFilePath());
|
package/src/guard-admin-api.ts
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import * as path from "node:path";
|
|
23
23
|
import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
|
|
24
|
+
import { findRepoRootFor } from "./_lib/baseline-compare";
|
|
24
25
|
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
26
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
25
27
|
|
|
26
28
|
const ROOT = process.cwd();
|
|
27
29
|
|
|
@@ -49,10 +51,14 @@ export interface Violation {
|
|
|
49
51
|
enclosingFunction: string;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
function collectViolations(sourceFile: SourceFile): Violation[] {
|
|
54
|
+
function collectViolations(sourceFile: SourceFile, roots: readonly RepoRoot[]): Violation[] {
|
|
53
55
|
const violations: Violation[] = [];
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
+
const filePath = sourceFile.getFilePath();
|
|
57
|
+
// Allowlist is repo-root-relative; the reported path stays cwd-relative for the security baseline.
|
|
58
|
+
const repoRoot = findRepoRootFor(filePath, roots);
|
|
59
|
+
const allowlistPath = path.relative(repoRoot?.absPath ?? ROOT, filePath);
|
|
60
|
+
const relativePath = path.relative(ROOT, filePath);
|
|
61
|
+
if (isAllowed(allowlistPath)) return violations;
|
|
56
62
|
|
|
57
63
|
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
58
64
|
for (const call of calls) {
|
|
@@ -112,13 +118,13 @@ export const guard: AstGuard = {
|
|
|
112
118
|
// App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline there too (infra#502).
|
|
113
119
|
security: true,
|
|
114
120
|
hint: "Admin API (appendRaw/appendRawBatch) bypasses the pipeline — only allowed in samples/*/migration/ or scripts/migrations/. For domain events: ctx.appendEvent / write handler.",
|
|
115
|
-
run(files) {
|
|
121
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
116
122
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
117
123
|
|
|
118
124
|
for (const sf of files) {
|
|
119
125
|
const file = sf.getFilePath();
|
|
120
126
|
if (EXCLUDE.test(file)) continue;
|
|
121
|
-
for (const v of collectViolations(sf)) {
|
|
127
|
+
for (const v of collectViolations(sf, roots)) {
|
|
122
128
|
violations.push({
|
|
123
129
|
file: v.file,
|
|
124
130
|
line: v.line,
|
|
@@ -287,11 +287,47 @@ function resolveWrite(
|
|
|
287
287
|
return undefined;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
// Callee names whose result is a table definition: `const xTable = <builder>(...)`.
|
|
291
|
+
const TABLE_BUILDER_NAMES: ReadonlySet<string> = new Set([
|
|
292
|
+
"pgTable",
|
|
293
|
+
"table",
|
|
294
|
+
"buildEntityTable",
|
|
295
|
+
"defineUnmanagedTable",
|
|
296
|
+
"deriveEntityTableMeta",
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
function isTableDeclaration(identifier: Identifier): boolean {
|
|
300
|
+
const symbol = identifier.getSymbol();
|
|
301
|
+
const resolved = symbol?.getAliasedSymbol() ?? symbol;
|
|
302
|
+
return (resolved?.getDeclarations() ?? []).some((decl) => {
|
|
303
|
+
if (decl.getKind() !== SyntaxKind.VariableDeclaration) return false;
|
|
304
|
+
const init = decl.asKindOrThrow(SyntaxKind.VariableDeclaration).getInitializer();
|
|
305
|
+
if (init?.getKind() !== SyntaxKind.CallExpression) return false;
|
|
306
|
+
const callee = init.asKindOrThrow(SyntaxKind.CallExpression).getExpression();
|
|
307
|
+
const calleeName =
|
|
308
|
+
callee.getKind() === SyntaxKind.PropertyAccessExpression
|
|
309
|
+
? callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName()
|
|
310
|
+
: callee.getText();
|
|
311
|
+
return TABLE_BUILDER_NAMES.has(calleeName);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// A write whose table argument resolves to a real table declaration — keeps
|
|
316
|
+
// `map.delete(key)` / `set.delete(id)` from counting as table writes.
|
|
317
|
+
function isTableWriteCall(call: CallExpression): boolean {
|
|
318
|
+
const write = resolveWrite(call);
|
|
319
|
+
return (
|
|
320
|
+
write !== undefined &&
|
|
321
|
+
declIdOf(write.tableArg) !== undefined &&
|
|
322
|
+
isTableDeclaration(write.tableArg)
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
290
326
|
function hasAnyTableWrite(files: readonly SourceFile[]): boolean {
|
|
291
327
|
for (const sf of files) {
|
|
292
328
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
293
329
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
294
|
-
if (
|
|
330
|
+
if (isTableWriteCall(call)) return true;
|
|
295
331
|
}
|
|
296
332
|
}
|
|
297
333
|
return false;
|
|
@@ -21,6 +21,7 @@
|
|
|
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
25
|
import {
|
|
25
26
|
type AstGuard,
|
|
26
27
|
findRepoRootFor,
|
|
@@ -79,13 +80,9 @@ function isSameOriginLiteralArg(expr: CallExpression): boolean {
|
|
|
79
80
|
return head.startsWith("/") && !head.startsWith("//") && head.length >= 2;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
) {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
const path = arg.getText().slice(1, -1);
|
|
83
|
+
// Runtime value, not source text: "/\/attacker.example" is "//attacker.example".
|
|
84
|
+
const path = literalReasonText(arg);
|
|
85
|
+
if (path === undefined) return false;
|
|
89
86
|
// Reject protocol-relative URLs ("//evil.example") — not same-origin.
|
|
90
87
|
return path.startsWith("/") && !path.startsWith("//");
|
|
91
88
|
}
|
|
@@ -561,7 +561,8 @@ function findUnsafeAllTenantsFindings(
|
|
|
561
561
|
line: prop.getStartLineNumber(),
|
|
562
562
|
rule: "unsafe-all-tenants-outside-declared-scope",
|
|
563
563
|
message:
|
|
564
|
-
'unsafeAllTenants used outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job,
|
|
564
|
+
'unsafeAllTenants used outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, declare { escapeHatch: { reason: "..." } } alongside it, or call declareEscapeHatch({ reason: "..." }) as a direct body statement of a named hook.' +
|
|
565
|
+
unresolvableDeclareEscapeHatchHint(call),
|
|
565
566
|
});
|
|
566
567
|
}
|
|
567
568
|
return out;
|
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
* from CLAUDE.md.
|
|
7
7
|
*
|
|
8
8
|
* Coverage rule: a feature counts as covered when an integration test
|
|
9
|
-
* imports anything
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
9
|
+
* imports anything that resolves into its
|
|
10
|
+
* `packages/bundled-features/src/<name>/` directory — either relatively, or
|
|
11
|
+
* via the subpath `@cosmicdrift/kumiko-bundled-features/<feature>`. That's
|
|
12
|
+
* deliberately broader than "imports feature.ts itself": a test that imports
|
|
13
|
+
* a sibling module of the feature (e.g. its resolver, its defaults helper)
|
|
14
|
+
* still proves the directory ran. Only subpath imports count: a barrel
|
|
15
|
+
* import without subpath (`@cosmicdrift/kumiko-bundled-features`) covers no
|
|
16
|
+
* feature. Since the `<name>-feature.ts` -> `feature.ts` rename (df3f6b5b)
|
|
17
|
+
* the file basename is identical for all 56 features — the directory name
|
|
18
|
+
* is the only usable identifier.
|
|
18
19
|
*
|
|
19
20
|
* Usage:
|
|
20
21
|
* bun guards/guard-feature-integration-tests.ts
|