Skip to content

g1t/packages/contracts/src/security-suite.ts

380 lines15,046 bytesCodeBlame
1import type { ServiceBinding } from "./clients";
2import type { User, Viewer } from "./identity";
3import type { RepoPath } from "./repos";
4import type { Result } from "./result";
5import type { AlertActivity, AlertState, DismissReason, SecretFinding, Severity, SeverityCounts, Vulnerability } from "./security";
6
7/**
8 * The security service's paid suite: custom secret patterns, push
9 * protection bypasses and their review, validity checks, code scanning
10 * from SARIF, the dependency graph with its SBOM and dependency review,
11 * and the workspace's overview. Mirrors `g1t_contracts::security_suite`.
12 *
13 * Free everywhere: secret scanning, push protection, vulnerability alerts
14 * and security updates. Free on public repositories: everything here. On a
15 * private repository the features in `PaidFeature` need the workspace's
16 * Security and quality activation (billing's `security` feature); a refusal
17 * is a `payment_required` failure saying how to turn it on.
18 */
19
20export type PaidFeature =
21 | "custom_patterns"
22 | "validity_checks"
23 | "delegated_bypass"
24 | "code_scanning"
25 | "dependency_review"
26 | "security_overview";
27
28export type AlertType = "secret_scanning" | "code_scanning" | "vulnerability";
29
30/** When a pull request's `Code scanning` check fails. */
31export type CodeScanningGate = "none" | "errors" | "critical" | "high" | "medium" | "any";
32
33export const CODE_SCANNING_GATES: { gate: CodeScanningGate; label: string }[] = [
34 { gate: "none", label: "Never" },
35 { gate: "errors", label: "Errors only" },
36 { gate: "critical", label: "Critical, and errors" },
37 { gate: "high", label: "High or higher, and errors" },
38 { gate: "medium", label: "Medium or higher, and errors" },
39 { gate: "any", label: "Any security result, and errors" },
40];
41
42export type ReviewFailOn = "critical" | "high" | "medium" | "low" | "none";
43
44/** The checks the suite reports on pull requests. */
45export const CODE_SCANNING_CHECK = "Code scanning";
46export const DEPENDENCY_REVIEW_CHECK = "Dependency review";
47/** The starter workflow "Set up code scanning" adds. */
48export const STARTER_WORKFLOW_PATH = ".g1t/workflows/code-scanning.yml";
49
50export type RepoSecuritySettings = {
51 codeScanningGate: CodeScanningGate;
52 dependencyReview: boolean;
53 reviewFailOn: ReviewFailOn;
54 reviewDenyLicenses: string[];
55 reviewComment: boolean;
56};
57
58export type WorkspaceSecuritySettings = {
59 delegatedBypass: boolean;
60 validityChecks: boolean;
61};
62
63export type SecuritySettingsView = {
64 settings: RepoSecuritySettings;
65 workspace: WorkspaceSecuritySettings;
66 private: boolean;
67 /** Public, or the workspace has the activation. */
68 entitled: boolean;
69 upkeep: boolean;
70};
71
72export type WorkspaceSecurityView = { settings: WorkspaceSecuritySettings; activated: boolean };
73
74export type CustomPattern = {
75 id: string;
76 scope: "repository" | "workspace";
77 workspace: string;
78 repo: string | null;
79 name: string;
80 pattern: string;
81 before: string | null;
82 after: string | null;
83 testStrings: string[];
84 state: "draft" | "published";
85 createdBy: string;
86 createdAt: string;
87 updatedBy: string;
88 updatedAt: string;
89 openAlerts: number;
90};
91
92export type PatternList = { patterns: CustomPattern[]; entitled: boolean };
93export type SavedPattern = { pattern: CustomPattern; tests: ([number, number] | null)[] };
94
95export type PatternMatch = { path: string; line: number; preview: string };
96export type DryRunRepo = { name: string; filesScanned: number; matches: PatternMatch[]; truncated: boolean; commit: string | null };
97export type DryRun = { repos: DryRunRepo[] };
98
99export type PatternInput = {
100 id?: string;
101 name: string;
102 pattern: string;
103 before?: string | null;
104 after?: string | null;
105 testStrings?: string[];
106 publish?: boolean;
107};
108
109export type BypassReason = "false_positive" | "used_in_tests" | "will_fix_later";
110
111export const BYPASS_REASONS: { reason: BypassReason; label: string; about: string }[] = [
112 { reason: "false_positive", label: "It's a false positive", about: "It is not a secret. The alert is closed as a false positive." },
113 { reason: "used_in_tests", label: "It's used in tests", about: "A value made for tests. The alert is closed as used in tests." },
114 { reason: "will_fix_later", label: "I'll fix it later", about: "It is real. The alert stays open until it is rotated." },
115];
116
117export type SecretLocation = { path: string; line: number; commit: string; source: "push" | "history"; foundAt: string };
118
119export type BypassRequest = {
120 id: string;
121 repoId: string;
122 workspace: string;
123 repo: string;
124 secretId: string;
125 label: string;
126 path: string;
127 line: number;
128 preview: string;
129 requester: string;
130 reason: BypassReason;
131 comment: string | null;
132 state: "pending" | "approved" | "denied" | "cancelled";
133 reviewer: string | null;
134 reviewComment: string | null;
135 createdAt: string;
136 reviewedAt: string | null;
137};
138
139export type SecretAlertDetail = {
140 secret: SecretFinding;
141 locations: SecretLocation[];
142 activity: AlertActivity[];
143 requests: BypassRequest[];
144 checkable: boolean;
145 canBypass: boolean;
146 canRequestBypass: boolean;
147};
148
149export type BypassResult = { secret: SecretFinding; request: BypassRequest | null };
150
151export type CodeAlert = {
152 id: string;
153 number: number;
154 repoId: string;
155 tool: string;
156 category: string;
157 ruleId: string;
158 ruleName: string | null;
159 ruleDescription: string | null;
160 help: string | null;
161 helpUri: string | null;
162 tags: string[];
163 level: "error" | "warning" | "note" | "none";
164 securitySeverity: Severity | null;
165 severity: Severity;
166 message: string;
167 path: string | null;
168 startLine: number | null;
169 endLine: number | null;
170 startColumn: number | null;
171 endColumn: number | null;
172 state: AlertState;
173 fingerprint: string;
174 firstCommit: string;
175 lastCommit: string;
176 createdAt: string;
177 updatedAt: string;
178 fixedAt: string | null;
179 dismissedBy: string | null;
180 dismissedReason: DismissReason | null;
181 dismissedComment: string | null;
182 dismissedAt: string | null;
183 issue: number | null;
184};
185
186export const CODE_DISMISS_REASONS: { reason: DismissReason; label: string; about: string }[] = [
187 { reason: "false_positive", label: "False positive", about: "The tool is wrong about this code." },
188 { reason: "wont_fix", label: "Won't fix", about: "It is real, and accepted as it is." },
189 { reason: "used_in_tests", label: "Used in tests", about: "The code is only in tests." },
190];
191
192export type Analysis = {
193 id: string;
194 repoId: string;
195 sarifId: string;
196 tool: string;
197 toolVersion: string | null;
198 category: string;
199 commitSha: string;
200 gitRef: string;
201 pull: number | null;
202 results: number;
203 newAlerts: number;
204 fixedAlerts: number;
205 dropped: number;
206 createdAt: string;
207};
208
209export type CodeScanning = { alerts: CodeAlert[]; analyses: Analysis[]; entitled: boolean; private: boolean; configured: boolean };
210export type CodeAlertDetail = { alert: CodeAlert; activity: AlertActivity[]; analyses: Analysis[] };
211
212export type PullResult = {
213 tool: string;
214 ruleId: string;
215 level: string;
216 severity: Severity;
217 securitySeverity: Severity | null;
218 message: string;
219 path: string | null;
220 line: number | null;
221 new: boolean;
222 onChangedLine: boolean;
223 failing: boolean;
224};
225
226export type ReviewVulnerability = { advisory: string; osvId: string; summary: string; severity: Severity; fixedVersion: string | null; url: string };
227
228export type ReviewChange = {
229 changeType: "added" | "removed";
230 manifest: string;
231 ecosystem: string;
232 name: string;
233 version: string;
234 relationship: "direct" | "transitive" | "unknown";
235 development: boolean;
236 license: string | null;
237 purl: string;
238 vulnerabilities: ReviewVulnerability[];
239 deniedLicense: boolean;
240 failing: boolean;
241};
242
243export type DependencyReview = {
244 base: string;
245 head: string;
246 changes: ReviewChange[];
247 passed: boolean;
248 headline: string;
249 failOn: string;
250 denyLicenses: string[];
251};
252
253export type PullScanning = {
254 commit: string | null;
255 results: PullResult[];
256 review: DependencyReview | null;
257 codeStatus: string | null;
258 codeDescription: string | null;
259};
260
261export type AlertFix = { issue: number; started: boolean; message: string | null };
262
263export type GraphDependency = {
264 ecosystem: string;
265 name: string;
266 version: string;
267 manifest: string;
268 relationship: "direct" | "transitive" | "unknown";
269 development: boolean;
270 license: string | null;
271 purl: string;
272 vulnerabilities: number;
273};
274
275export type GraphManifest = { path: string; ecosystem: string; dependencies: number; direct: number };
276export type DependencyGraph = { commit: string | null; manifests: GraphManifest[]; dependencies: GraphDependency[] };
277
278export type TypeTotals = { alertType: AlertType; open: SeverityCounts; opened: number; closed: number };
279export type TrendPoint = { day: string; secretScanning: number; codeScanning: number; vulnerability: number };
280export type RepoCoverage = {
281 repoId: string;
282 name: string;
283 private: boolean;
284 customPatterns: number;
285 validityChecks: boolean;
286 codeScanningAt: string | null;
287 dependencyReview: boolean;
288 securityUpdates: boolean;
289 lockfiles: number;
290 secrets: SeverityCounts;
291 code: SeverityCounts;
292 vulnerabilities: SeverityCounts;
293};
294export type WorkspaceOverview = {
295 activated: boolean;
296 privateHidden: number;
297 totals: TypeTotals[];
298 trend: TrendPoint[];
299 repos: RepoCoverage[];
300};
301
302export type WorkspaceAlert = { repo: string; secret?: SecretFinding | null; code?: CodeAlert | null; vulnerability?: Vulnerability | null };
303
304export interface SecuritySuiteApi {
305 settings(repo: RepoPath, viewer: Viewer): Promise<Result<SecuritySettingsView>>;
306 setSettings(actor: User, repo: RepoPath, settings: RepoSecuritySettings): Promise<Result<SecuritySettingsView>>;
307 workspaceSettings(workspace: string, viewer: Viewer): Promise<Result<WorkspaceSecurityView>>;
308 setWorkspaceSettings(actor: User, workspace: string, settings: WorkspaceSecuritySettings): Promise<Result<WorkspaceSecurityView>>;
309 patterns(workspace: string, repo: RepoPath | null, viewer: Viewer): Promise<Result<PatternList>>;
310 savePattern(actor: User, workspace: string, repo: RepoPath | null, pattern: PatternInput): Promise<Result<SavedPattern>>;
311 deletePattern(actor: User, workspace: string, repo: RepoPath | null, id: string): Promise<Result<boolean>>;
312 dryRun(actor: User, workspace: string, repo: RepoPath | null, pattern: { pattern: string; before?: string | null; after?: string | null }, repos?: string[]): Promise<Result<DryRun>>;
313 secretAlert(repo: RepoPath, id: string, viewer: Viewer): Promise<Result<SecretAlertDetail>>;
314 bypass(actor: User, repo: RepoPath, id: string, reason: BypassReason, comment: string): Promise<Result<BypassResult>>;
315 bypassRequests(workspace: string, viewer: Viewer, state?: string | null): Promise<Result<BypassRequest[]>>;
316 reviewBypass(actor: User, workspace: string, id: string, decision: "approve" | "deny" | "cancel", comment: string): Promise<Result<BypassRequest>>;
317 checkValidity(actor: User, repo: RepoPath, id: string): Promise<Result<SecretFinding>>;
318 codeScanning(repo: RepoPath, viewer: Viewer): Promise<Result<CodeScanning>>;
319 codeAlert(repo: RepoPath, number: number, viewer: Viewer): Promise<Result<CodeAlertDetail>>;
320 setCodeAlertState(actor: User, repo: RepoPath, number: number, state: "open" | "dismissed", reason: DismissReason | null, comment: string): Promise<Result<CodeAlert>>;
321 pullScanning(repo: RepoPath, number: number, viewer: Viewer): Promise<Result<PullScanning>>;
322 fixAlert(actor: User, repo: RepoPath, id: string): Promise<Result<AlertFix>>;
323 dependencyGraph(repo: RepoPath, viewer: Viewer): Promise<Result<DependencyGraph>>;
324 sbom(repo: RepoPath, viewer: Viewer): Promise<Result<unknown>>;
325 overview(workspace: string, viewer: Viewer, days?: number): Promise<Result<WorkspaceOverview>>;
326}
327
328export function securitySuiteClient(service: ServiceBinding): SecuritySuiteApi {
329 const call = async <T>(method: string, args: object): Promise<T> => {
330 const response = await service.fetch(`https://service/rpc/${method}`, {
331 method: "POST",
332 headers: { "content-type": "application/json" },
333 body: JSON.stringify(args),
334 });
335 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
336 return (await response.json()) as T;
337 };
338 return {
339 settings: (repo, viewer) => call("security_settings", { repo, viewer }),
340 setSettings: (actor, repo, settings) => call("set_security_settings", { actor, repo, settings }),
341 workspaceSettings: (workspace, viewer) => call("workspace_security_settings", { workspace, viewer }),
342 setWorkspaceSettings: (actor, workspace, settings) => call("set_workspace_security_settings", { actor, workspace, settings }),
343 patterns: (workspace, repo, viewer) => call("custom_patterns", { workspace, repo, viewer }),
344 savePattern: (actor, workspace, repo, pattern) =>
345 call("save_custom_pattern", {
346 actor,
347 workspace,
348 repo,
349 id: pattern.id ?? null,
350 name: pattern.name,
351 pattern: pattern.pattern,
352 before: pattern.before ?? null,
353 after: pattern.after ?? null,
354 testStrings: pattern.testStrings ?? [],
355 publish: pattern.publish ?? false,
356 }),
357 deletePattern: (actor, workspace, repo, id) => call("delete_custom_pattern", { actor, workspace, repo, id }),
358 dryRun: (actor, workspace, repo, pattern, repos = []) =>
359 call("dry_run_pattern", { actor, workspace, repo, repos, pattern: pattern.pattern, before: pattern.before ?? null, after: pattern.after ?? null }),
360 secretAlert: (repo, id, viewer) => call("secret_alert", { repo, id, viewer }),
361 bypass: (actor, repo, id, reason, comment) => call("bypass", { actor, repo, id, reason, comment }),
362 bypassRequests: (workspace, viewer, state = null) => call("bypass_requests", { workspace, viewer, state }),
363 reviewBypass: (actor, workspace, id, decision, comment) => call("review_bypass", { actor, workspace, id, decision, comment }),
364 checkValidity: (actor, repo, id) => call("check_validity", { actor, repo, id }),
365 codeScanning: (repo, viewer) => call("code_scanning", { repo, viewer }),
366 codeAlert: (repo, number, viewer) => call("code_alert", { repo, number, viewer }),
367 setCodeAlertState: (actor, repo, number, state, reason, comment) =>
368 call("set_code_alert_state", { actor, repo, number, state, reason, comment }),
369 pullScanning: (repo, number, viewer) => call("pull_code_scanning", { repo, number, viewer }),
370 fixAlert: (actor, repo, id) => call("fix_alert", { actor, repo, id }),
371 dependencyGraph: (repo, viewer) => call("dependency_graph", { repo, viewer }),
372 sbom: (repo, viewer) => call("sbom", { repo, viewer }),
373 overview: (workspace, viewer, days) => call("security_overview", { workspace, viewer, days: days ?? null }),
374 };
375}
376
377/** Whether a result failed for want of the Security and quality activation. */
378export function needsActivation(result: Result<unknown>): boolean {
379 return !result.ok && result.error.code === "payment_required";
380}