@cosmicdrift/kumiko-guards 0.319.0 → 0.321.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/changes.json +14 -0
- package/src/guard-event-store-writes.ts +124 -0
- package/src/guard-no-direct-fs.ts +5 -0
- package/src/pre-push.sh +31 -0
- package/src/run-guards.ts +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.321.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.321.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.321.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "pre-push.sh refuses to push a stale .kumiko/upgrade-state.json",
|
|
6
|
+
"detail": "When `.kumiko/upgrade-state.json` exists at the repo root, `pre-push.sh`\nnow runs the upgrade-state guard before its main check and refuses the\npush if the guard fails, resolving `guard-upgrade-state.ts` next to the\nhook's real (symlink-resolved) script location.",
|
|
7
|
+
"migration": "A repo that has adopted `.kumiko/upgrade-state.json` and is currently\nstale now has its push blocked until the upgrade state is reconciled. A\nrepo without that marker file is unaffected."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.321.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "New guard blocks direct writes to the event-store tables outside the event store itself",
|
|
13
|
+
"detail": "guard-event-store-writes.ts flags any UPDATE/DELETE FROM/INSERT INTO\nstring or template literal naming kumiko_events, kumiko_snapshots, or\nkumiko_archived_streams outside packages/framework/src/event-store/**\nand their db/queries/event-store*.ts backing (plus the two pre-existing,\nnamed one-time backfill exceptions). Registered in run-guards.ts.",
|
|
14
|
+
"migration": "No action needed for code that already goes through the event store's\nown primitives (append, transferAggregateStreams, archiveStream, ...).\nA direct raw-SQL write against one of these three tables from feature\ncode now fails the guard; use transferAggregateStreams for a tenant\nmove, or add a new event-store primitive instead."
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"version": "0.317.0",
|
|
4
18
|
"type": "fix",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: flags string/template literals containing UPDATE/DELETE FROM/INSERT
|
|
4
|
+
* INTO against kumiko_events, kumiko_snapshots or kumiko_archived_streams
|
|
5
|
+
* outside the event store's own files.
|
|
6
|
+
*
|
|
7
|
+
* These three tables ARE the event store. A direct write from elsewhere
|
|
8
|
+
* bypasses its invariants (version-unique index, archive markers, tenant
|
|
9
|
+
* boundary). Tenant moves go through `transferAggregateStreams`
|
|
10
|
+
* (event-store/transfer.ts); any other need belongs in
|
|
11
|
+
* db/queries/event-store*.ts.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* bun packages/guards/src/guard-event-store-writes.ts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
18
|
+
import {
|
|
19
|
+
type AstGuard,
|
|
20
|
+
type GuardViolation,
|
|
21
|
+
relFromRepoRoot,
|
|
22
|
+
runStandalone,
|
|
23
|
+
type ScanSpec,
|
|
24
|
+
} from "./_lib/guard-kit";
|
|
25
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
26
|
+
|
|
27
|
+
const SCAN: ScanSpec = { scope: "source", extensions: ["ts"] };
|
|
28
|
+
|
|
29
|
+
// Tests fabricate their own fixture rows directly — they are the primary
|
|
30
|
+
// verifiers of the event-store primitives, not a write surface this guard
|
|
31
|
+
// needs to police.
|
|
32
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
33
|
+
|
|
34
|
+
// Files allowed to write these three tables directly:
|
|
35
|
+
// - event-store/** — the primitive's own home (transfer.ts, event-store.ts,
|
|
36
|
+
// snapshot.ts, archive.ts, admin-api.ts, rebuild-dead-letter.ts, ...).
|
|
37
|
+
// - db/queries/event-store*.ts — the raw-SQL backing queries the primitives
|
|
38
|
+
// call (event-store.ts, event-store-admin.ts, event-store-transfer.ts).
|
|
39
|
+
// - db/queries/backfill-pii.ts — the one-time pre-KMS plaintext backfill;
|
|
40
|
+
// rewrites payload/deletes stale snapshots in place, framework-owned.
|
|
41
|
+
// - user/db/queries/stream-tenant-backfill.ts — a one-time stream-ownership
|
|
42
|
+
// migration that predates this guard; already carries its own
|
|
43
|
+
// RAW_SQL_ALLOWLIST entry, kept here for the same reason.
|
|
44
|
+
const ALLOWLIST: readonly RegExp[] = [
|
|
45
|
+
/^packages\/framework\/src\/event-store\//,
|
|
46
|
+
/^packages\/framework\/src\/db\/queries\/event-store.*\.ts$/,
|
|
47
|
+
/^packages\/framework\/src\/db\/queries\/backfill-pii\.ts$/,
|
|
48
|
+
/^packages\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const ES_TABLES = "kumiko_events|kumiko_snapshots|kumiko_archived_streams";
|
|
52
|
+
// One string/template literal must carry both the write verb AND the table
|
|
53
|
+
// name — a keyword in one concatenated piece and the table name (behind a
|
|
54
|
+
// variable) in another is indirection this guard does not see through; the
|
|
55
|
+
// event store's own queries are the only place that literal belongs.
|
|
56
|
+
const WRITE_RE = new RegExp(
|
|
57
|
+
`\\b(UPDATE|DELETE\\s+FROM|INSERT\\s+INTO)\\b[\\s\\S]*?["'\`]?(${ES_TABLES})["'\`]?`,
|
|
58
|
+
"i",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const LITERAL_KINDS = [
|
|
62
|
+
SyntaxKind.StringLiteral,
|
|
63
|
+
SyntaxKind.NoSubstitutionTemplateLiteral,
|
|
64
|
+
SyntaxKind.TemplateExpression,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export type Violation = {
|
|
68
|
+
readonly file: string;
|
|
69
|
+
readonly line: number;
|
|
70
|
+
readonly snippet: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function isAllowed(relativePath: string): boolean {
|
|
74
|
+
return ALLOWLIST.some((re) => re.test(relativePath));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function collectViolations(
|
|
78
|
+
sourceFile: SourceFile,
|
|
79
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
80
|
+
): Violation[] {
|
|
81
|
+
const violations: Violation[] = [];
|
|
82
|
+
// Repo-root-relative, not `process.cwd()`-relative — a multi-repo runner
|
|
83
|
+
// (e.g. invoked from the parent workspace dir) would otherwise carry a
|
|
84
|
+
// leading `kumiko-framework/` segment that the ALLOWLIST regexes (anchored
|
|
85
|
+
// on `^packages/...`) never match, wrongly flagging every allowed file.
|
|
86
|
+
const relativePath = relFromRepoRoot(sourceFile.getFilePath(), roots);
|
|
87
|
+
if (isAllowed(relativePath)) return violations;
|
|
88
|
+
|
|
89
|
+
for (const kind of LITERAL_KINDS) {
|
|
90
|
+
for (const node of sourceFile.getDescendantsOfKind(kind)) {
|
|
91
|
+
const text = node.getText();
|
|
92
|
+
const match = WRITE_RE.exec(text);
|
|
93
|
+
if (!match) continue;
|
|
94
|
+
violations.push({
|
|
95
|
+
file: relativePath,
|
|
96
|
+
line: node.getStartLineNumber(),
|
|
97
|
+
snippet: match[0].replace(/\s+/g, " ").trim().slice(0, 120),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return violations;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const guard: AstGuard = {
|
|
105
|
+
name: "Event-Store-Writes Guard",
|
|
106
|
+
scan: SCAN,
|
|
107
|
+
hint:
|
|
108
|
+
"kumiko_events/kumiko_snapshots/kumiko_archived_streams may only be written from " +
|
|
109
|
+
"packages/framework/src/event-store/** and their db/queries/event-store*.ts backing. " +
|
|
110
|
+
"Use transferAggregateStreams (event-store/transfer.ts) for a tenant move, or add a " +
|
|
111
|
+
"new event-store primitive instead of writing these tables from feature code.",
|
|
112
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
113
|
+
const violations: GuardViolation[] = [];
|
|
114
|
+
for (const sf of files) {
|
|
115
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
116
|
+
for (const v of collectViolations(sf, roots)) {
|
|
117
|
+
violations.push({ file: v.file, line: v.line, message: v.snippet });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { violations };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -126,6 +126,11 @@ const ALLOWLIST: readonly AllowEntry[] = [
|
|
|
126
126
|
{ repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/build\.ts$/ },
|
|
127
127
|
{ repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/feature\.ts$/ },
|
|
128
128
|
{ repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/validate\.ts$/ },
|
|
129
|
+
|
|
130
|
+
// testing: `kumiko-testing integration` CLI resolves positional test-file
|
|
131
|
+
// args against the developer's cwd, local dev/CI tooling reading paths
|
|
132
|
+
// the developer typed — no server request input.
|
|
133
|
+
{ repo: "kumiko-framework", pattern: /^packages\/testing\/src\/integration-runner\.ts$/ },
|
|
129
134
|
];
|
|
130
135
|
|
|
131
136
|
export function isRepoAllowlisted(
|
package/src/pre-push.sh
CHANGED
|
@@ -86,6 +86,37 @@ if git -C "$REPO_ROOT" ls-files --error-unmatch scripts/pre-push-extra.sh >/dev/
|
|
|
86
86
|
(cd "$REPO_ROOT" && KUMIKO_PUSH_REPO_ROOT="$REPO_ROOT" KUMIKO_PUSH_PARENT_DIR="$PARENT_DIR" "$REPO_ROOT/scripts/pre-push-extra.sh" "$@") || exit 1
|
|
87
87
|
fi
|
|
88
88
|
|
|
89
|
+
# Upgrade-state guard: a repo that has adopted the `.kumiko/upgrade-state.json`
|
|
90
|
+
# marker must not push with it stale. Installed as a bin, `$0` here is the
|
|
91
|
+
# node_modules/.bin symlink, not this file's real path — resolve the symlink
|
|
92
|
+
# chain so guard-upgrade-state.ts is found next to the real script, not $0.
|
|
93
|
+
resolve_script_dir() {
|
|
94
|
+
SOURCE="$1"
|
|
95
|
+
while [ -h "$SOURCE" ]; do
|
|
96
|
+
DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
|
|
97
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
98
|
+
case "$SOURCE" in
|
|
99
|
+
/*) ;;
|
|
100
|
+
*) SOURCE="$DIR/$SOURCE" ;;
|
|
101
|
+
esac
|
|
102
|
+
done
|
|
103
|
+
cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if [ -f "$REPO_ROOT/.kumiko/upgrade-state.json" ]; then
|
|
107
|
+
SCRIPT_DIR="$(resolve_script_dir "$0")"
|
|
108
|
+
GUARD_UPGRADE_STATE="$SCRIPT_DIR/guard-upgrade-state.ts"
|
|
109
|
+
if [ ! -f "$GUARD_UPGRADE_STATE" ]; then
|
|
110
|
+
echo "[pre-push] FATAL: guard-upgrade-state.ts not found next to resolved script location '$SCRIPT_DIR' — refusing push" >&2
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
echo "[pre-push] .kumiko/upgrade-state.json present — running upgrade-state guard…"
|
|
114
|
+
if ! (cd "$REPO_ROOT" && bun "$GUARD_UPGRADE_STATE"); then
|
|
115
|
+
echo "[pre-push] upgrade-state guard failed — refusing push" >&2
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
fi
|
|
119
|
+
|
|
89
120
|
# infra#559/#569: from a worktree, `--git-common-dir` resolves to the main
|
|
90
121
|
# checkout's .git, so the branch below would scope `bun check` to the
|
|
91
122
|
# canonical sibling path instead of this worktree — checking the wrong
|
package/src/run-guards.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
reportResults,
|
|
18
18
|
runGuards,
|
|
19
19
|
} from "./_lib/guard-kit";
|
|
20
|
+
import type { RepoRoot } from "./_lib/roots";
|
|
20
21
|
import { writeSecurityBaselines } from "./_lib/security-baseline-cli";
|
|
21
22
|
import { guard as asCasts } from "./check-as-casts";
|
|
22
23
|
import { guard as complexity } from "./check-complexity";
|
|
@@ -30,6 +31,7 @@ import { guard as directEntityWrites } from "./guard-direct-entity-writes";
|
|
|
30
31
|
import { guard as directFetch } from "./guard-direct-fetch";
|
|
31
32
|
import { guard as errorReasons } from "./guard-error-reasons";
|
|
32
33
|
import { guard as escapeHatchDeclared } from "./guard-escape-hatch-declared";
|
|
34
|
+
import { guard as eventStoreWrites } from "./guard-event-store-writes";
|
|
33
35
|
import { guard as fakeTests } from "./guard-fake-tests";
|
|
34
36
|
import { guard as htmlEscape } from "./guard-html-escape";
|
|
35
37
|
import { guard as i18nKeys } from "./guard-i18n-keys";
|
|
@@ -87,6 +89,7 @@ export const GUARDS = [
|
|
|
87
89
|
writeHandlerQns,
|
|
88
90
|
asCasts,
|
|
89
91
|
loadallEvents,
|
|
92
|
+
eventStoreWrites,
|
|
90
93
|
tableDdl,
|
|
91
94
|
appFeatureStructure,
|
|
92
95
|
libTestCoverage,
|
|
@@ -103,10 +106,14 @@ export const GUARD_FLAGS = [
|
|
|
103
106
|
|
|
104
107
|
const GUARD_NAME_PREFIX = "--guard=";
|
|
105
108
|
|
|
109
|
+
export type RunGuardsCliDeps = {
|
|
110
|
+
readonly writeBaselineRoots?: readonly RepoRoot[];
|
|
111
|
+
};
|
|
112
|
+
|
|
106
113
|
// Shared by the direct `bun run-guards.ts` invocation below and by the
|
|
107
114
|
// `guards` subcommand in cli.ts — one place for the flag behavior so the
|
|
108
115
|
// two entry points can never drift.
|
|
109
|
-
export function runGuardsCli(argv: readonly string[]): number {
|
|
116
|
+
export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {}): number {
|
|
110
117
|
const guardNameArg = argv.find((arg) => arg.startsWith(GUARD_NAME_PREFIX));
|
|
111
118
|
const flags = guardNameArg === undefined ? argv : argv.filter((arg) => arg !== guardNameArg);
|
|
112
119
|
const flagsError = cliFlagsError("guards", flags, GUARD_FLAGS);
|
|
@@ -148,8 +155,8 @@ export function runGuardsCli(argv: readonly string[]): number {
|
|
|
148
155
|
console.error(`Guard "${guardName}" has no ratchet baseline to write.`);
|
|
149
156
|
return 1;
|
|
150
157
|
}
|
|
151
|
-
const project = buildSharedProject([target]);
|
|
152
|
-
target.writeBaseline(filesForGuard(project, target));
|
|
158
|
+
const project = buildSharedProject([target], deps.writeBaselineRoots);
|
|
159
|
+
target.writeBaseline(filesForGuard(project, target, deps.writeBaselineRoots));
|
|
153
160
|
return 0;
|
|
154
161
|
}
|
|
155
162
|
const strictSecurityBaseline = flags.includes("--strict-security-baseline");
|