@checkstack/script-packages-backend 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/drizzle/0002_dry_sue_storm.sql +27 -0
- package/drizzle/meta/0002_snapshot.json +666 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +9 -6
- package/src/audit-delta.test.ts +127 -0
- package/src/audit-delta.ts +100 -0
- package/src/audit-parse.test.ts +128 -0
- package/src/audit-parse.ts +147 -0
- package/src/audit-runner.test.ts +230 -0
- package/src/audit-runner.ts +224 -0
- package/src/audit-scanner.test.ts +101 -0
- package/src/audit-scanner.ts +156 -0
- package/src/hooks.ts +14 -0
- package/src/index.ts +264 -3
- package/src/router.ts +49 -0
- package/src/sandbox-policy-router.test.ts +105 -0
- package/src/sandbox-policy.test.ts +119 -0
- package/src/sandbox-policy.ts +68 -0
- package/src/sandbox-startup-log.test.ts +128 -0
- package/src/sandbox-startup-log.ts +83 -0
- package/src/schema.ts +53 -1
- package/src/sdk-types-route.test.ts +121 -0
- package/src/sdk-types-route.ts +137 -0
- package/src/stores.ts +216 -1
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { AuthService, Logger } from "@checkstack/backend-api";
|
|
2
|
+
import {
|
|
3
|
+
parseSdkTypesPath,
|
|
4
|
+
scriptPackagesAccess,
|
|
5
|
+
SDK_TYPES_PATH_PREFIX,
|
|
6
|
+
} from "@checkstack/script-packages-common";
|
|
7
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Raw, HTTP-cacheable route serving the running platform release's generated
|
|
11
|
+
* `@checkstack/sdk` editor bundle (`core/sdk/generated/editor-bundle.d.ts`)
|
|
12
|
+
* for the in-app TypeScript script editor. Mounted (via
|
|
13
|
+
* `rpc.registerHttpHandler`) at `/api/script-packages/sdk-types/:releaseVersion`.
|
|
14
|
+
*
|
|
15
|
+
* Why a raw route and not an oRPC procedure: identical to the type-acquisition
|
|
16
|
+
* route - oRPC responses here can't set custom HTTP headers, and we want
|
|
17
|
+
* HTTP-level caching keyed to the running release. So we serve a raw handler
|
|
18
|
+
* and set `Cache-Control` directly.
|
|
19
|
+
*
|
|
20
|
+
* Caching: the path carries the running release version, and the response sets
|
|
21
|
+
* `Cache-Control: private, max-age=<1y>, immutable`. The SDK bundle is
|
|
22
|
+
* immutable for the life of a deployed release; a deployment upgrade changes
|
|
23
|
+
* the version, so the editor requests a fresh URL and never reuses the old
|
|
24
|
+
* cache entry. `private` because the surface (contract names) may be sensitive.
|
|
25
|
+
*
|
|
26
|
+
* Staleness: if the client requests a version != the running version (e.g. a
|
|
27
|
+
* tab open across a deployment upgrade), we return `409` so the client refetches
|
|
28
|
+
* the current version and remounts the fresh types - never serving stale SDK
|
|
29
|
+
* declarations.
|
|
30
|
+
*
|
|
31
|
+
* Auth: a raw route bypasses oRPC auto-auth, so we authenticate and enforce the
|
|
32
|
+
* same global `script-packages.read` access the editor's ATA route uses.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** One year. The bundle is immutable for the life of a deployed release. */
|
|
36
|
+
const CACHE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
|
37
|
+
|
|
38
|
+
/** The virtual path the editor mounts the bundle at (a `node_modules` path so
|
|
39
|
+
* `@checkstack/sdk` + subpath resolution finds it). */
|
|
40
|
+
export const SDK_BUNDLE_VIRTUAL_PATH =
|
|
41
|
+
"node_modules/@checkstack/sdk/editor-bundle.d.ts";
|
|
42
|
+
|
|
43
|
+
interface CreateSdkTypesHttpHandlerDeps {
|
|
44
|
+
auth: AuthService;
|
|
45
|
+
/** The running platform release version (e.g. `@checkstack/release`). */
|
|
46
|
+
getReleaseVersion: () => string;
|
|
47
|
+
/** The generated SDK editor bundle (`editor-bundle.d.ts`) content. */
|
|
48
|
+
getSdkBundle: () => string;
|
|
49
|
+
logger: Logger;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One acquired file, mirroring the type-acquisition response shape so the
|
|
53
|
+
* frontend reuses the same `registerAcquiredFiles` mount path. */
|
|
54
|
+
interface SdkTypesFile {
|
|
55
|
+
path: string;
|
|
56
|
+
content: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether the authenticated user holds global `script-packages.read`. */
|
|
60
|
+
function hasReadAccess(
|
|
61
|
+
user: Awaited<ReturnType<AuthService["authenticate"]>>,
|
|
62
|
+
): boolean {
|
|
63
|
+
if (!user) return false;
|
|
64
|
+
if (user.type === "service") return true;
|
|
65
|
+
const rules = user.accessRules ?? [];
|
|
66
|
+
return rules.includes("*") || rules.includes(scriptPackagesAccess.read.id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonResponse(body: unknown, status: number): Response {
|
|
70
|
+
return Response.json(body, { status });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createSdkTypesHttpHandler({
|
|
74
|
+
auth,
|
|
75
|
+
getReleaseVersion,
|
|
76
|
+
getSdkBundle,
|
|
77
|
+
logger,
|
|
78
|
+
}: CreateSdkTypesHttpHandlerDeps): (req: Request) => Promise<Response> {
|
|
79
|
+
return async (req: Request): Promise<Response> => {
|
|
80
|
+
if (req.method !== "GET") {
|
|
81
|
+
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Auth ─────────────────────────────────────────────────────────────
|
|
85
|
+
let user: Awaited<ReturnType<AuthService["authenticate"]>>;
|
|
86
|
+
try {
|
|
87
|
+
user = await auth.authenticate(req);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
logger.error(`SDK-types auth failed: ${extractErrorMessage(error)}`);
|
|
90
|
+
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
91
|
+
}
|
|
92
|
+
if (!user) return jsonResponse({ error: "Unauthorized" }, 401);
|
|
93
|
+
if (!hasReadAccess(user)) {
|
|
94
|
+
return jsonResponse({ error: "Forbidden" }, 403);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Parse path ───────────────────────────────────────────────────────
|
|
98
|
+
const pathname = new URL(req.url).pathname;
|
|
99
|
+
const prefixIndex = pathname.indexOf(SDK_TYPES_PATH_PREFIX);
|
|
100
|
+
if (prefixIndex === -1) {
|
|
101
|
+
return jsonResponse({ error: "Not found" }, 404);
|
|
102
|
+
}
|
|
103
|
+
const afterPrefix = pathname.slice(
|
|
104
|
+
prefixIndex + SDK_TYPES_PATH_PREFIX.length,
|
|
105
|
+
);
|
|
106
|
+
const parsed = parseSdkTypesPath(afterPrefix);
|
|
107
|
+
if (!parsed) {
|
|
108
|
+
return jsonResponse({ error: "Bad request" }, 400);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Version must match the running release ───────────────────────────
|
|
112
|
+
const runningVersion = getReleaseVersion();
|
|
113
|
+
if (parsed.releaseVersion !== runningVersion) {
|
|
114
|
+
// Stale (e.g. a tab open across a deployment upgrade). Don't cache a
|
|
115
|
+
// mismatch; tell the client to refetch the current version.
|
|
116
|
+
return jsonResponse(
|
|
117
|
+
{ error: "Stale release version; refetch SDK types." },
|
|
118
|
+
409,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const files: SdkTypesFile[] = [
|
|
123
|
+
{ path: SDK_BUNDLE_VIRTUAL_PATH, content: getSdkBundle() },
|
|
124
|
+
];
|
|
125
|
+
return Response.json(
|
|
126
|
+
{ files },
|
|
127
|
+
{
|
|
128
|
+
status: 200,
|
|
129
|
+
headers: {
|
|
130
|
+
// Immutable for the life of this release; the version-keyed path
|
|
131
|
+
// changes on upgrade so the old URL is never reused.
|
|
132
|
+
"Cache-Control": `private, max-age=${CACHE_MAX_AGE_SECONDS}, immutable`,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/stores.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { eq, ne, desc, sql } from "drizzle-orm";
|
|
1
|
+
import { and, eq, ne, desc, sql } from "drizzle-orm";
|
|
2
2
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
3
|
import type {
|
|
4
|
+
AuditAdvisory,
|
|
5
|
+
AuditState,
|
|
4
6
|
BlobGcState,
|
|
5
7
|
ManifestEntry,
|
|
6
8
|
PackageSpec,
|
|
@@ -23,6 +25,8 @@ import {
|
|
|
23
25
|
scriptPackageBlobGcState,
|
|
24
26
|
scriptPackageLockfileHistory,
|
|
25
27
|
scriptPackageSatelliteState,
|
|
28
|
+
scriptPackageAuditAdvisory,
|
|
29
|
+
scriptPackageAuditState,
|
|
26
30
|
} from "./schema";
|
|
27
31
|
|
|
28
32
|
const SINGLETON = "singleton";
|
|
@@ -531,3 +535,214 @@ export function createSatelliteStateStore(db: SafeDatabase<SatelliteSchema>) {
|
|
|
531
535
|
},
|
|
532
536
|
};
|
|
533
537
|
}
|
|
538
|
+
|
|
539
|
+
// ─── Vulnerability-audit store ─────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
type AuditSchema = {
|
|
542
|
+
scriptPackageAuditAdvisory: typeof scriptPackageAuditAdvisory;
|
|
543
|
+
scriptPackageAuditState: typeof scriptPackageAuditState;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const DEFAULT_AUDIT_STATE: AuditState = {
|
|
547
|
+
lastRunAt: null,
|
|
548
|
+
lockfileHash: null,
|
|
549
|
+
total: 0,
|
|
550
|
+
counts: { low: 0, moderate: 0, high: 0, critical: 0 },
|
|
551
|
+
errorMessage: null,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
function auditRowToAdvisory(r: {
|
|
555
|
+
packageName: string;
|
|
556
|
+
advisoryId: string;
|
|
557
|
+
title: string;
|
|
558
|
+
severity: AuditAdvisory["severity"];
|
|
559
|
+
vulnerableVersions: string;
|
|
560
|
+
url: string | null;
|
|
561
|
+
cvssScore: number | null;
|
|
562
|
+
}): AuditAdvisory {
|
|
563
|
+
return {
|
|
564
|
+
packageName: r.packageName,
|
|
565
|
+
advisoryId: r.advisoryId,
|
|
566
|
+
title: r.title,
|
|
567
|
+
severity: r.severity,
|
|
568
|
+
vulnerableVersions: r.vulnerableVersions,
|
|
569
|
+
url: r.url,
|
|
570
|
+
cvssScore: r.cvssScore,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Persists the audit findings (cluster-wide source of truth) + the singleton
|
|
576
|
+
* last-run summary. The on-disk node_modules tree is pod-local; advisories
|
|
577
|
+
* live here so any pod returns the same answer (state-and-scale rule).
|
|
578
|
+
*/
|
|
579
|
+
export function createAuditStore(db: SafeDatabase<AuditSchema>) {
|
|
580
|
+
return {
|
|
581
|
+
/** Advisories stored for a given lockfile hash (sorted for stable diffing). */
|
|
582
|
+
async advisoriesForHash(lockfileHash: string): Promise<AuditAdvisory[]> {
|
|
583
|
+
const rows = await db
|
|
584
|
+
.select()
|
|
585
|
+
.from(scriptPackageAuditAdvisory)
|
|
586
|
+
.where(eq(scriptPackageAuditAdvisory.lockfileHash, lockfileHash));
|
|
587
|
+
return rows
|
|
588
|
+
.map((r) => auditRowToAdvisory(r))
|
|
589
|
+
.toSorted((a, b) =>
|
|
590
|
+
a.packageName === b.packageName
|
|
591
|
+
? a.advisoryId.localeCompare(b.advisoryId)
|
|
592
|
+
: a.packageName.localeCompare(b.packageName),
|
|
593
|
+
);
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Advisory keys (`<package> <advisoryId>`) already marked notified at
|
|
598
|
+
* (at least) the given severity, so a redeploy doesn't re-notify an
|
|
599
|
+
* unchanged set. Returns a map of key -> the severity last notified at.
|
|
600
|
+
*/
|
|
601
|
+
async notifiedSeverities(
|
|
602
|
+
lockfileHash: string,
|
|
603
|
+
): Promise<Map<string, AuditAdvisory["severity"]>> {
|
|
604
|
+
const rows = await db
|
|
605
|
+
.select({
|
|
606
|
+
packageName: scriptPackageAuditAdvisory.packageName,
|
|
607
|
+
advisoryId: scriptPackageAuditAdvisory.advisoryId,
|
|
608
|
+
severity: scriptPackageAuditAdvisory.severity,
|
|
609
|
+
notified: scriptPackageAuditAdvisory.notified,
|
|
610
|
+
})
|
|
611
|
+
.from(scriptPackageAuditAdvisory)
|
|
612
|
+
.where(eq(scriptPackageAuditAdvisory.lockfileHash, lockfileHash));
|
|
613
|
+
const map = new Map<string, AuditAdvisory["severity"]>();
|
|
614
|
+
for (const r of rows) {
|
|
615
|
+
if (r.notified) map.set(`${r.packageName} ${r.advisoryId}`, r.severity);
|
|
616
|
+
}
|
|
617
|
+
return map;
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Replace the entire advisory set for a hash with `advisories`. `notified`
|
|
622
|
+
* is carried from `notifiedKeys` so already-notified advisories don't
|
|
623
|
+
* re-notify on the next pass. Done in one transaction so a reader never
|
|
624
|
+
* sees a half-written set.
|
|
625
|
+
*/
|
|
626
|
+
async replaceForHash(input: {
|
|
627
|
+
lockfileHash: string;
|
|
628
|
+
advisories: AuditAdvisory[];
|
|
629
|
+
notifiedKeys: Set<string>;
|
|
630
|
+
}): Promise<void> {
|
|
631
|
+
const { lockfileHash, advisories, notifiedKeys } = input;
|
|
632
|
+
await db.transaction(async (tx) => {
|
|
633
|
+
await tx
|
|
634
|
+
.delete(scriptPackageAuditAdvisory)
|
|
635
|
+
.where(eq(scriptPackageAuditAdvisory.lockfileHash, lockfileHash));
|
|
636
|
+
if (advisories.length > 0) {
|
|
637
|
+
await tx.insert(scriptPackageAuditAdvisory).values(
|
|
638
|
+
advisories.map((a) => ({
|
|
639
|
+
lockfileHash,
|
|
640
|
+
advisoryId: a.advisoryId,
|
|
641
|
+
packageName: a.packageName,
|
|
642
|
+
title: a.title,
|
|
643
|
+
severity: a.severity,
|
|
644
|
+
vulnerableVersions: a.vulnerableVersions,
|
|
645
|
+
url: a.url,
|
|
646
|
+
cvssScore: a.cvssScore,
|
|
647
|
+
notified: notifiedKeys.has(`${a.packageName} ${a.advisoryId}`),
|
|
648
|
+
})),
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
/** Mark the given advisory keys notified for a hash (post-send). */
|
|
655
|
+
async markNotified(input: {
|
|
656
|
+
lockfileHash: string;
|
|
657
|
+
keys: { packageName: string; advisoryId: string }[];
|
|
658
|
+
}): Promise<void> {
|
|
659
|
+
const { lockfileHash, keys } = input;
|
|
660
|
+
if (keys.length === 0) return;
|
|
661
|
+
// Each key is (package, advisory); update them in one statement per
|
|
662
|
+
// package-grouped set is awkward, so update by advisory id within hash
|
|
663
|
+
// and re-confirm the package in the WHERE.
|
|
664
|
+
for (const k of keys) {
|
|
665
|
+
await db
|
|
666
|
+
.update(scriptPackageAuditAdvisory)
|
|
667
|
+
.set({ notified: true })
|
|
668
|
+
.where(
|
|
669
|
+
and(
|
|
670
|
+
eq(scriptPackageAuditAdvisory.lockfileHash, lockfileHash),
|
|
671
|
+
eq(scriptPackageAuditAdvisory.advisoryId, k.advisoryId),
|
|
672
|
+
eq(scriptPackageAuditAdvisory.packageName, k.packageName),
|
|
673
|
+
),
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
/** Drop advisories for hashes no longer relevant (housekeeping). */
|
|
679
|
+
async pruneHashesNotIn(keepHashes: string[]): Promise<void> {
|
|
680
|
+
if (keepHashes.length === 0) {
|
|
681
|
+
await db.delete(scriptPackageAuditAdvisory);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
await db
|
|
685
|
+
.delete(scriptPackageAuditAdvisory)
|
|
686
|
+
.where(
|
|
687
|
+
sql`${scriptPackageAuditAdvisory.lockfileHash} NOT IN (${sql.join(
|
|
688
|
+
keepHashes.map((h) => sql`${h}`),
|
|
689
|
+
sql`, `,
|
|
690
|
+
)})`,
|
|
691
|
+
);
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
async getState(): Promise<AuditState> {
|
|
695
|
+
const [row] = await db
|
|
696
|
+
.select()
|
|
697
|
+
.from(scriptPackageAuditState)
|
|
698
|
+
.where(eq(scriptPackageAuditState.id, SINGLETON))
|
|
699
|
+
.limit(1);
|
|
700
|
+
if (!row) return DEFAULT_AUDIT_STATE;
|
|
701
|
+
return {
|
|
702
|
+
lastRunAt: row.lastRunAt,
|
|
703
|
+
lockfileHash: row.lockfileHash,
|
|
704
|
+
total: row.total,
|
|
705
|
+
counts: {
|
|
706
|
+
low: row.countLow,
|
|
707
|
+
moderate: row.countModerate,
|
|
708
|
+
high: row.countHigh,
|
|
709
|
+
critical: row.countCritical,
|
|
710
|
+
},
|
|
711
|
+
errorMessage: row.errorMessage,
|
|
712
|
+
};
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
/** Record a successful run summary. */
|
|
716
|
+
async recordRun(input: {
|
|
717
|
+
lockfileHash: string | null;
|
|
718
|
+
total: number;
|
|
719
|
+
counts: AuditState["counts"];
|
|
720
|
+
}): Promise<void> {
|
|
721
|
+
const set = {
|
|
722
|
+
lastRunAt: new Date(),
|
|
723
|
+
lockfileHash: input.lockfileHash,
|
|
724
|
+
total: input.total,
|
|
725
|
+
countLow: input.counts.low,
|
|
726
|
+
countModerate: input.counts.moderate,
|
|
727
|
+
countHigh: input.counts.high,
|
|
728
|
+
countCritical: input.counts.critical,
|
|
729
|
+
errorMessage: null,
|
|
730
|
+
};
|
|
731
|
+
await db
|
|
732
|
+
.insert(scriptPackageAuditState)
|
|
733
|
+
.values({ id: SINGLETON, ...set })
|
|
734
|
+
.onConflictDoUpdate({ target: scriptPackageAuditState.id, set });
|
|
735
|
+
},
|
|
736
|
+
|
|
737
|
+
/** Record a failed run (keeps prior counts; surfaces the error). */
|
|
738
|
+
async recordError(message: string): Promise<void> {
|
|
739
|
+
const set = { lastRunAt: new Date(), errorMessage: message };
|
|
740
|
+
await db
|
|
741
|
+
.insert(scriptPackageAuditState)
|
|
742
|
+
.values({ id: SINGLETON, ...set })
|
|
743
|
+
.onConflictDoUpdate({ target: scriptPackageAuditState.id, set });
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export type AuditStore = ReturnType<typeof createAuditStore>;
|
package/tsconfig.json
CHANGED
|
@@ -4,15 +4,24 @@
|
|
|
4
4
|
"src"
|
|
5
5
|
],
|
|
6
6
|
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../auth-common"
|
|
9
|
+
},
|
|
7
10
|
{
|
|
8
11
|
"path": "../backend-api"
|
|
9
12
|
},
|
|
10
13
|
{
|
|
11
14
|
"path": "../common"
|
|
12
15
|
},
|
|
16
|
+
{
|
|
17
|
+
"path": "../notification-common"
|
|
18
|
+
},
|
|
13
19
|
{
|
|
14
20
|
"path": "../script-packages-common"
|
|
15
21
|
},
|
|
22
|
+
{
|
|
23
|
+
"path": "../sdk"
|
|
24
|
+
},
|
|
16
25
|
{
|
|
17
26
|
"path": "../secrets-backend"
|
|
18
27
|
},
|