@superblocksteam/shared 0.9608.2 → 0.9609.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/dist/jwt/verifier.d.ts.map +1 -1
- package/dist/jwt/verifier.js +6 -1
- package/dist/jwt/verifier.js.map +1 -1
- package/dist/jwt/verifier.test.js +64 -2
- package/dist/jwt/verifier.test.js.map +1 -1
- package/dist/types/application/index.d.ts +8 -0
- package/dist/types/application/index.d.ts.map +1 -1
- package/dist/types/application/index.js.map +1 -1
- package/dist/types/audit/ocsf.d.ts.map +1 -1
- package/dist/types/audit/ocsf.js +3 -1
- package/dist/types/audit/ocsf.js.map +1 -1
- package/dist/types/common/response.d.ts +8 -1
- package/dist/types/common/response.d.ts.map +1 -1
- package/dist/types/common/response.js +7 -0
- package/dist/types/common/response.js.map +1 -1
- package/dist/types/datasource/auth.d.ts +1 -0
- package/dist/types/datasource/auth.d.ts.map +1 -1
- package/dist/types/datasource/auth.js +26 -0
- package/dist/types/datasource/auth.js.map +1 -1
- package/dist/types/datasource/auth.test.js +57 -0
- package/dist/types/datasource/auth.test.js.map +1 -1
- package/dist/types/policyGate/index.d.ts +37 -0
- package/dist/types/policyGate/index.d.ts.map +1 -1
- package/dist/types/policyGate/index.js +17 -0
- package/dist/types/policyGate/index.js.map +1 -1
- package/dist/types/response/DeleteApplicationResponseBody.d.ts +2 -2
- package/dist/types/response/DeleteApplicationResponseBody.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/launchDarkly.d.ts +18 -0
- package/dist/utils/launchDarkly.d.ts.map +1 -0
- package/dist/utils/launchDarkly.js +21 -0
- package/dist/utils/launchDarkly.js.map +1 -0
- package/dist/utils/launchDarkly.test.d.ts +2 -0
- package/dist/utils/launchDarkly.test.d.ts.map +1 -0
- package/dist/utils/launchDarkly.test.js +24 -0
- package/dist/utils/launchDarkly.test.js.map +1 -0
- package/dist-esm/jwt/verifier.d.ts.map +1 -1
- package/dist-esm/jwt/verifier.js +6 -1
- package/dist-esm/jwt/verifier.js.map +1 -1
- package/dist-esm/jwt/verifier.test.js +64 -2
- package/dist-esm/jwt/verifier.test.js.map +1 -1
- package/dist-esm/types/application/index.d.ts +8 -0
- package/dist-esm/types/application/index.d.ts.map +1 -1
- package/dist-esm/types/application/index.js.map +1 -1
- package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
- package/dist-esm/types/audit/ocsf.js +3 -1
- package/dist-esm/types/audit/ocsf.js.map +1 -1
- package/dist-esm/types/common/response.d.ts +8 -1
- package/dist-esm/types/common/response.d.ts.map +1 -1
- package/dist-esm/types/common/response.js +7 -0
- package/dist-esm/types/common/response.js.map +1 -1
- package/dist-esm/types/datasource/auth.d.ts +1 -0
- package/dist-esm/types/datasource/auth.d.ts.map +1 -1
- package/dist-esm/types/datasource/auth.js +25 -0
- package/dist-esm/types/datasource/auth.js.map +1 -1
- package/dist-esm/types/datasource/auth.test.js +58 -1
- package/dist-esm/types/datasource/auth.test.js.map +1 -1
- package/dist-esm/types/policyGate/index.d.ts +37 -0
- package/dist-esm/types/policyGate/index.d.ts.map +1 -1
- package/dist-esm/types/policyGate/index.js +15 -0
- package/dist-esm/types/policyGate/index.js.map +1 -1
- package/dist-esm/types/response/DeleteApplicationResponseBody.d.ts +2 -2
- package/dist-esm/types/response/DeleteApplicationResponseBody.d.ts.map +1 -1
- package/dist-esm/utils/index.d.ts +1 -0
- package/dist-esm/utils/index.d.ts.map +1 -1
- package/dist-esm/utils/index.js +1 -0
- package/dist-esm/utils/index.js.map +1 -1
- package/dist-esm/utils/launchDarkly.d.ts +18 -0
- package/dist-esm/utils/launchDarkly.d.ts.map +1 -0
- package/dist-esm/utils/launchDarkly.js +18 -0
- package/dist-esm/utils/launchDarkly.js.map +1 -0
- package/dist-esm/utils/launchDarkly.test.d.ts +2 -0
- package/dist-esm/utils/launchDarkly.test.d.ts.map +1 -0
- package/dist-esm/utils/launchDarkly.test.js +22 -0
- package/dist-esm/utils/launchDarkly.test.js.map +1 -0
- package/package.json +1 -1
- package/src/jwt/verifier.test.ts +62 -4
- package/src/jwt/verifier.ts +6 -1
- package/src/types/application/index.ts +11 -0
- package/src/types/audit/ocsf.ts +3 -1
- package/src/types/common/response.ts +8 -1
- package/src/types/datasource/auth.test.ts +80 -0
- package/src/types/datasource/auth.ts +32 -0
- package/src/types/policyGate/index.ts +53 -0
- package/src/types/response/DeleteApplicationResponseBody.ts +2 -2
- package/src/utils/index.ts +1 -0
- package/src/utils/launchDarkly.test.ts +25 -0
- package/src/utils/launchDarkly.ts +23 -0
package/src/jwt/verifier.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generateKeyPairSync } from 'node:crypto';
|
|
2
|
+
import { createServer, type Socket } from 'node:net';
|
|
2
3
|
|
|
3
4
|
import { SignJWT, importPKCS8 } from 'jose';
|
|
4
5
|
import { type JWTPayload } from 'jose';
|
|
@@ -17,12 +18,17 @@ const createSigningMaterial = (kid = KEY_ID): SigningMaterial => {
|
|
|
17
18
|
modulusLength: 2048
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
const
|
|
21
|
-
|
|
21
|
+
const exportedJwk = publicKey.export({ format: 'jwk' });
|
|
22
|
+
if (!exportedJwk.kty) {
|
|
23
|
+
throw new Error('Generated public key is missing kty');
|
|
24
|
+
}
|
|
25
|
+
const jwk: JwtKey = {
|
|
26
|
+
...exportedJwk,
|
|
27
|
+
kty: exportedJwk.kty,
|
|
22
28
|
kid,
|
|
23
29
|
alg: 'RS256',
|
|
24
30
|
use: 'sig'
|
|
25
|
-
}
|
|
31
|
+
};
|
|
26
32
|
|
|
27
33
|
return {
|
|
28
34
|
jwk,
|
|
@@ -42,6 +48,14 @@ const buildJwtVerifier = (jwk: SigningMaterial['jwk']) => {
|
|
|
42
48
|
return { verifier: new JwtVerifier(options), getKeysInterceptor };
|
|
43
49
|
};
|
|
44
50
|
|
|
51
|
+
const errorCodes = (error: unknown): string[] => {
|
|
52
|
+
if (!error || typeof error !== 'object') return [];
|
|
53
|
+
const codes: string[] = [];
|
|
54
|
+
if ('code' in error && typeof error.code === 'string') codes.push(error.code);
|
|
55
|
+
if ('cause' in error) codes.push(...errorCodes(error.cause));
|
|
56
|
+
return codes;
|
|
57
|
+
};
|
|
58
|
+
|
|
45
59
|
describe('JwtVerifier', () => {
|
|
46
60
|
let signingMaterial: SigningMaterial;
|
|
47
61
|
beforeAll(() => {
|
|
@@ -79,7 +93,8 @@ describe('JwtVerifier', () => {
|
|
|
79
93
|
|
|
80
94
|
await expect(verifier.verify(invalidToken, { algorithms: ['RS256'] })).rejects.toMatchObject({
|
|
81
95
|
status: 401,
|
|
82
|
-
message: expect.stringContaining('signature verification failed')
|
|
96
|
+
message: expect.stringContaining('signature verification failed'),
|
|
97
|
+
cause: expect.objectContaining({ code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' })
|
|
83
98
|
});
|
|
84
99
|
});
|
|
85
100
|
|
|
@@ -163,4 +178,47 @@ describe('JwtVerifier', () => {
|
|
|
163
178
|
await expect(verifier.verify(token, { algorithms: ['ES256'] })).rejects.toMatchObject({ status: 401 });
|
|
164
179
|
expect(getKeysInterceptor).not.toHaveBeenCalled();
|
|
165
180
|
});
|
|
181
|
+
|
|
182
|
+
it('preserves malformed remote JWKS protocol failures for callers to classify', async () => {
|
|
183
|
+
const sockets = new Set<Socket>();
|
|
184
|
+
const server = createServer((socket) => {
|
|
185
|
+
sockets.add(socket);
|
|
186
|
+
socket.once('close', () => sockets.delete(socket));
|
|
187
|
+
socket.end('NOT HTTP\r\n\r\n');
|
|
188
|
+
});
|
|
189
|
+
await new Promise<void>((resolve, reject) => {
|
|
190
|
+
server.once('error', reject);
|
|
191
|
+
server.listen(0, '127.0.0.1', () => {
|
|
192
|
+
server.off('error', reject);
|
|
193
|
+
resolve();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const address = server.address();
|
|
199
|
+
if (!address || typeof address === 'string') throw new Error('Expected a TCP address');
|
|
200
|
+
const verifier = new JwtVerifier({ jwksUri: `http://127.0.0.1:${address.port}/jwks` });
|
|
201
|
+
const key = await importPKCS8(signingMaterial.privateKeyPem, 'RS256');
|
|
202
|
+
const token = await new SignJWT({ sub: 'user-bad-jwks' })
|
|
203
|
+
.setProtectedHeader({ alg: 'RS256', kid: signingMaterial.jwk.kid })
|
|
204
|
+
.sign(key);
|
|
205
|
+
|
|
206
|
+
let failure: unknown;
|
|
207
|
+
try {
|
|
208
|
+
await verifier.verify(token, { algorithms: ['RS256'] });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
failure = error;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
expect(errorCodes(failure)).toContain('HPE_INVALID_CONSTANT');
|
|
214
|
+
} finally {
|
|
215
|
+
for (const socket of sockets) socket.destroy();
|
|
216
|
+
await new Promise<void>((resolve, reject) => {
|
|
217
|
+
server.close((error) => {
|
|
218
|
+
if (error) reject(error);
|
|
219
|
+
else resolve();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
166
224
|
});
|
package/src/jwt/verifier.ts
CHANGED
|
@@ -44,7 +44,12 @@ export class JwtVerifier {
|
|
|
44
44
|
const result = await jwtVerify<T>(token, jwkSet, options);
|
|
45
45
|
return result.payload;
|
|
46
46
|
} catch (err) {
|
|
47
|
-
|
|
47
|
+
// Keep the original error reachable. jose tags it with a `code` that is
|
|
48
|
+
// the only reliable way to tell an expired token from a key lookup that
|
|
49
|
+
// timed out, and callers need that to answer the client honestly.
|
|
50
|
+
const failure = new UnauthorizedError(`JWT verification failed: ${(err as Error).message}`);
|
|
51
|
+
failure.cause = err;
|
|
52
|
+
throw failure;
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -472,6 +472,17 @@ export interface IApplicationV2Dto {
|
|
|
472
472
|
removedProfiles?: Record<string, Array<{ id: string; key: string; displayName: string }>>;
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
export type DatabaseDisposition = 'delete' | 'retain';
|
|
476
|
+
|
|
477
|
+
export type DatabaseRetirementEnqueueFailure = {
|
|
478
|
+
applicationId: string;
|
|
479
|
+
bindingKey: string;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
export type DatabaseRetirementEnqueueResult = {
|
|
483
|
+
databaseRetirementEnqueueFailures: DatabaseRetirementEnqueueFailure[];
|
|
484
|
+
};
|
|
485
|
+
|
|
475
486
|
export interface IHomepageApplicationV2Dto extends IResourceMetadata {
|
|
476
487
|
deployedAt?: Date | null;
|
|
477
488
|
creator?: {
|
package/src/types/audit/ocsf.ts
CHANGED
|
@@ -289,7 +289,9 @@ export const AUDIT_EVENT_TYPE_CATALOG: AuditEventTypeEntry[] = [
|
|
|
289
289
|
'reviewpolicy.allowlist.applications.added',
|
|
290
290
|
'reviewpolicy.allowlist.applications.removed',
|
|
291
291
|
'reviewpolicy.create',
|
|
292
|
-
'reviewpolicy.delete'
|
|
292
|
+
'reviewpolicy.delete',
|
|
293
|
+
'reviewpolicy.disable',
|
|
294
|
+
'reviewpolicy.enable'
|
|
293
295
|
]
|
|
294
296
|
},
|
|
295
297
|
{
|
|
@@ -72,7 +72,14 @@ export enum SuperblocksError {
|
|
|
72
72
|
APIKeyInvalid = 'APIKeyInvalid',
|
|
73
73
|
AuthenticatedUserNotFound = 'AuthenticatedUserNotFound',
|
|
74
74
|
UserTokenInvalid = 'UserTokenInvalid',
|
|
75
|
-
JWTExpired = 'JWTExpired'
|
|
75
|
+
JWTExpired = 'JWTExpired',
|
|
76
|
+
/**
|
|
77
|
+
* We could not tell whether the token is good, because looking up the
|
|
78
|
+
* signing key failed (the request timed out, or the connection was
|
|
79
|
+
* refused). The token itself may be perfectly valid, so this is not a
|
|
80
|
+
* reason for the client to give up the way an expiry or a bad signature is.
|
|
81
|
+
*/
|
|
82
|
+
JWTVerificationUnavailable = 'JWTVerificationUnavailable'
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
/**
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
TokenScope,
|
|
6
6
|
extractPublic,
|
|
7
7
|
getAuthId,
|
|
8
|
+
getAuthIdCandidates,
|
|
8
9
|
getDisplayName,
|
|
9
10
|
getOAuthCodeVerifierStorageKey,
|
|
10
11
|
isPkceEnabled
|
|
@@ -229,6 +230,85 @@ describe('getAuthId normalizes boolean tokenScope in getClientId', () => {
|
|
|
229
230
|
});
|
|
230
231
|
});
|
|
231
232
|
|
|
233
|
+
describe('getAuthIdCandidates', () => {
|
|
234
|
+
test('user-scoped config also probes the org-unique configId keying (recognizes a datasource->user flip)', () => {
|
|
235
|
+
const authConfig = { clientId: 'my-client', tokenScope: TokenScope.USER, scope: 'read' };
|
|
236
|
+
const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1');
|
|
237
|
+
// The config's own current keying (user -> client id) PLUS the widened datasource keying (config id).
|
|
238
|
+
expect(candidates).toContain(getAuthId(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1'));
|
|
239
|
+
expect(candidates).toContain(
|
|
240
|
+
getAuthId(
|
|
241
|
+
IntegrationAuthType.OAUTH2_CODE,
|
|
242
|
+
{ clientId: 'my-client', tokenScope: TokenScope.DATASOURCE, scope: 'read' },
|
|
243
|
+
'int-1',
|
|
244
|
+
'cfg-1'
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
expect(candidates).toHaveLength(2);
|
|
248
|
+
expect(candidates.some((c) => c.startsWith('oauth-code.cfg-1'))).toBe(true); // org-unique config id
|
|
249
|
+
expect(candidates.some((c) => c.startsWith('oauth-code.my-client'))).toBe(true); // its own current client-id key
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('datasource-scoped config widens ONLY to the configId keying, never synthesizes the client-id keying (SEC-73)', () => {
|
|
253
|
+
// The client-id keying is shared across tenants (many orgs can share a clientId + scope); synthesizing
|
|
254
|
+
// it for a datasource-scoped config would let one org witness ownership of another org's client-id-keyed
|
|
255
|
+
// shared token. It must never appear as a candidate for a datasource-scoped config.
|
|
256
|
+
const authConfig = { clientId: 'shared-client', tokenScope: TokenScope.DATASOURCE, scope: 'read' };
|
|
257
|
+
const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1');
|
|
258
|
+
expect(candidates).toEqual([getAuthId(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1')]);
|
|
259
|
+
expect(candidates).toHaveLength(1);
|
|
260
|
+
expect(candidates[0]).toMatch(/^oauth-code\.cfg-1/); // org-unique config id
|
|
261
|
+
expect(candidates.some((c) => c.startsWith('oauth-code.shared-client'))).toBe(false); // no cross-tenant client-id key
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('collapses to one candidate when config id equals client id', () => {
|
|
265
|
+
const authConfig = { clientId: 'same', tokenScope: TokenScope.USER, scope: 'read' };
|
|
266
|
+
const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'same');
|
|
267
|
+
expect(candidates).toHaveLength(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test.each([IntegrationAuthType.OAUTH2_CLIENT_CREDS, IntegrationAuthType.OAUTH2_IMPLICIT])(
|
|
271
|
+
'OAuth-split type %s (user-scoped) also probes the configId keying',
|
|
272
|
+
(authType) => {
|
|
273
|
+
const authConfig = { clientId: 'my-client', tokenScope: TokenScope.USER, scope: 'read' };
|
|
274
|
+
const candidates = getAuthIdCandidates(authType, authConfig, 'int-1', 'cfg-1');
|
|
275
|
+
expect(candidates).toHaveLength(2);
|
|
276
|
+
expect(candidates).toContain(
|
|
277
|
+
getAuthId(authType, { clientId: 'my-client', tokenScope: TokenScope.DATASOURCE, scope: 'read' }, 'int-1', 'cfg-1')
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
test.each([IntegrationAuthType.BASIC, IntegrationAuthType.FIREBASE, IntegrationAuthType.OAUTH2_PASSWORD])(
|
|
283
|
+
'scope-invariant type %s returns a single candidate (tokenScope does not affect keying)',
|
|
284
|
+
(authType) => {
|
|
285
|
+
const authConfig = { clientId: 'my-client', apiKey: '{"projectId":"p"}', scope: 'read' };
|
|
286
|
+
const candidates = getAuthIdCandidates(authType, authConfig, 'int-1', 'cfg-1');
|
|
287
|
+
expect(candidates).toHaveLength(1);
|
|
288
|
+
expect(candidates[0]).toBe(getAuthId(authType, authConfig, 'int-1', 'cfg-1'));
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
test('scope-invariant auth type returns a single candidate (tokenScope does not affect keying)', () => {
|
|
293
|
+
const authConfig = {
|
|
294
|
+
subjectTokenSource: 'SUBJECT_TOKEN_SOURCE_STATIC_TOKEN',
|
|
295
|
+
subjectTokenSourceStaticToken: 't',
|
|
296
|
+
tokenUrl: 'https://idp.example.com/token',
|
|
297
|
+
scope: 'read',
|
|
298
|
+
audience: 'aud',
|
|
299
|
+
clientId: 'c'
|
|
300
|
+
};
|
|
301
|
+
const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, authConfig, 'int-1', 'cfg-1');
|
|
302
|
+
expect(candidates).toHaveLength(1);
|
|
303
|
+
expect(candidates[0]).toBe(getAuthId(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, authConfig, 'int-1', 'cfg-1'));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('undefined authConfig yields a single candidate', () => {
|
|
307
|
+
const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_CODE, undefined, 'int-1', 'cfg-1');
|
|
308
|
+
expect(candidates).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
232
312
|
describe('getAuthId', () => {
|
|
233
313
|
test('uses unknown-client for OAUTH2_IDP_TOKEN_PASSTHROUGH (no client identity)', () => {
|
|
234
314
|
const authId = getAuthId(IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH, {}, 'int-1', 'cfg-1');
|
|
@@ -60,6 +60,38 @@ export function getAuthId(
|
|
|
60
60
|
return `${authType}.${getClientId(authType, authConfig, integrationId, integrationConfigurationId)}`;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// A token minted while a config's tokenScope was 'datasource' keys on the org-unique configuration id;
|
|
64
|
+
// after the config is flipped to 'user' a fresh recompute keys on the client id instead and no longer
|
|
65
|
+
// matches, false-denying the legitimate owner. getAuthIdCandidates returns the config's current-scope
|
|
66
|
+
// authId PLUS the datasource (configuration-id) keying, so an ownership recompute recognizes a token
|
|
67
|
+
// across a post-mint datasource->user flip.
|
|
68
|
+
//
|
|
69
|
+
// It widens ONLY toward the datasource keying, which is org-unique (the configuration id is a per-config
|
|
70
|
+
// UUID). It deliberately does NOT synthesize the user (client-id) keying for a datasource-scoped config:
|
|
71
|
+
// the client-id key is shared across tenants (multiple orgs can share a clientId + scope), so adding it
|
|
72
|
+
// would let one org's config witness ownership of another org's client-id-keyed shared token. Recognizing
|
|
73
|
+
// the reverse user->datasource flip would require matching that shared key, so it is intentionally not
|
|
74
|
+
// done here; closing it safely needs org-scoped token storage, tracked separately. See SEC-73.
|
|
75
|
+
export function getAuthIdCandidates(
|
|
76
|
+
authType: AuthType | undefined,
|
|
77
|
+
authConfig: AuthConfig | undefined,
|
|
78
|
+
integrationId?: string,
|
|
79
|
+
integrationConfigurationId?: string
|
|
80
|
+
): AuthId[] {
|
|
81
|
+
const current = getAuthId(authType, authConfig, integrationId, integrationConfigurationId);
|
|
82
|
+
const asDatasource = getAuthId(authType, withTokenScope(authConfig, TokenScope.DATASOURCE), integrationId, integrationConfigurationId);
|
|
83
|
+
return current === asDatasource ? [current] : [current, asDatasource];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Return a shallow copy of authConfig with tokenScope overridden, without mutating the caller's object.
|
|
87
|
+
// undefined passes through so getAuthId falls back to its unknown-client key.
|
|
88
|
+
function withTokenScope(authConfig: AuthConfig | undefined, tokenScope: TokenScope): AuthConfig | undefined {
|
|
89
|
+
if (!authConfig) {
|
|
90
|
+
return authConfig;
|
|
91
|
+
}
|
|
92
|
+
return { ...authConfig, tokenScope } as AuthConfig;
|
|
93
|
+
}
|
|
94
|
+
|
|
63
95
|
function getClientId(
|
|
64
96
|
authType: AuthType | undefined,
|
|
65
97
|
config: AuthConfig | undefined,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReviewFindingCategory, ReviewFindingConfidence, ReviewFindingSeverity } from '../reviewPolicy/index.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @deprecated Use the `superblocks.policy-gates.security-scans.enabled` LaunchDarkly flag instead.
|
|
3
5
|
* Kept as `false` to match the fail-closed LD default; create and enable the
|
|
@@ -68,6 +70,25 @@ export type PolicyGateReadinessItem = {
|
|
|
68
70
|
status: PolicyGateReadinessItemStatus;
|
|
69
71
|
};
|
|
70
72
|
|
|
73
|
+
export function isHardBlockingPolicyItem(item: Pick<PolicyGateReadinessItem, 'mode' | 'status'>): boolean {
|
|
74
|
+
if (item.mode !== 'blocking' && item.mode !== 'approval') {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return item.status === 'blocked' || item.status === 'failed';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether every item is an outcome an acknowledged "Publish anyway" hold is
|
|
82
|
+
* allowed to bypass. `missing`/`stale`/`running`/`failed`/`approval_required`
|
|
83
|
+
* mean some gate has not finished or hard-blocks unconditionally; an ack
|
|
84
|
+
* granted while any of those exist could waive a review that never happened.
|
|
85
|
+
*/
|
|
86
|
+
export function areReadinessItemsAckEligible(items: Pick<PolicyGateReadinessItem, 'status'>[]): boolean {
|
|
87
|
+
return (
|
|
88
|
+
items.length > 0 && items.every((item) => item.status === 'blocked' || item.status === 'passed' || item.status === 'advisory_findings')
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
export type PolicyGateReadinessRun = {
|
|
72
93
|
completedAt?: string;
|
|
73
94
|
createdAt?: string;
|
|
@@ -122,3 +143,35 @@ export const POLICY_GATE_BLOCKED_ERROR = 'POLICY_GATE_BLOCKED';
|
|
|
122
143
|
*/
|
|
123
144
|
export const POLICY_GATE_STALE_LIVE_EDIT_ERROR =
|
|
124
145
|
'Policy gates can only run for the current app state. Deploy the current changes or restore to this commit and then deploy.';
|
|
146
|
+
|
|
147
|
+
export type ScanScopeChangeType = 'added' | 'deleted' | 'modified';
|
|
148
|
+
|
|
149
|
+
export type ScanScopeChangedFile = {
|
|
150
|
+
changeType: ScanScopeChangeType;
|
|
151
|
+
path: string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type ScanScopePriorFinding = {
|
|
155
|
+
blocking: boolean;
|
|
156
|
+
category: ReviewFindingCategory;
|
|
157
|
+
clarkRemediable?: boolean;
|
|
158
|
+
confidence?: ReviewFindingConfidence | null;
|
|
159
|
+
evidenceJson?: Record<string, unknown>;
|
|
160
|
+
fingerprint: string;
|
|
161
|
+
humanSummary?: string | null;
|
|
162
|
+
locations: unknown[];
|
|
163
|
+
remediationHintJson?: Record<string, unknown>;
|
|
164
|
+
severity: ReviewFindingSeverity;
|
|
165
|
+
technicalSummary?: string | null;
|
|
166
|
+
title: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export type ScanScopeJson = {
|
|
170
|
+
baseApplicationCommitId: string;
|
|
171
|
+
changedFiles: ScanScopeChangedFile[];
|
|
172
|
+
diffTruncated: boolean;
|
|
173
|
+
priorAiAgentRunId?: string;
|
|
174
|
+
priorFindings: ScanScopePriorFinding[];
|
|
175
|
+
priorReviewRunId?: string;
|
|
176
|
+
unifiedDiff: string;
|
|
177
|
+
};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { IApplicationV2Dto } from '../application/index.js';
|
|
1
|
+
import type { DatabaseRetirementEnqueueResult, IApplicationV2Dto } from '../application/index.js';
|
|
2
2
|
|
|
3
|
-
export type DeleteApplicationResponseBody = IApplicationV2Dto
|
|
3
|
+
export type DeleteApplicationResponseBody = IApplicationV2Dto & Partial<DatabaseRetirementEnqueueResult>;
|
package/src/utils/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './configuration.js';
|
|
|
6
6
|
export * from './env.js';
|
|
7
7
|
export * from './environment.js';
|
|
8
8
|
export * from './git-url.js';
|
|
9
|
+
export * from './launchDarkly.js';
|
|
9
10
|
export * from './maskCredential.js';
|
|
10
11
|
export * from './normalize.js';
|
|
11
12
|
export * from './normalizeRoleName.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { shouldSendLaunchDarklyEvents } from './launchDarkly.js';
|
|
4
|
+
|
|
5
|
+
describe('shouldSendLaunchDarklyEvents', () => {
|
|
6
|
+
test('sends events by default when unset', () => {
|
|
7
|
+
expect(shouldSendLaunchDarklyEvents(undefined)).toBe(true);
|
|
8
|
+
expect(shouldSendLaunchDarklyEvents(null)).toBe(true);
|
|
9
|
+
expect(shouldSendLaunchDarklyEvents('')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('only an explicit false disables events', () => {
|
|
13
|
+
expect(shouldSendLaunchDarklyEvents('false')).toBe(false);
|
|
14
|
+
expect(shouldSendLaunchDarklyEvents('FALSE')).toBe(false);
|
|
15
|
+
expect(shouldSendLaunchDarklyEvents(' false ')).toBe(false);
|
|
16
|
+
expect(shouldSendLaunchDarklyEvents(false)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('sends events for truthy and unrecognized values', () => {
|
|
20
|
+
expect(shouldSendLaunchDarklyEvents('true')).toBe(true);
|
|
21
|
+
expect(shouldSendLaunchDarklyEvents(true)).toBe(true);
|
|
22
|
+
expect(shouldSendLaunchDarklyEvents('0')).toBe(true);
|
|
23
|
+
expect(shouldSendLaunchDarklyEvents('no')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A LaunchDarkly send-events setting as it arrives from configuration: either an
|
|
3
|
+
* already-parsed boolean or the raw string form an env var carries.
|
|
4
|
+
*/
|
|
5
|
+
export type LaunchDarklySendEventsSetting = boolean | string | undefined | null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Analytics events drive LaunchDarkly's per-context billing: monthly active users are
|
|
9
|
+
* counted from the unique context keys LaunchDarkly receives events for. Throwaway
|
|
10
|
+
* contexts, such as the per-run organizations created by end-to-end tests, each bill as
|
|
11
|
+
* an additional monthly active user, so events should be off outside real deployments.
|
|
12
|
+
*
|
|
13
|
+
* Events are on by default and only an explicit 'false' turns them off, matching
|
|
14
|
+
* SUPERBLOCKS_SERVER_LAUNCH_DARKLY_SEND_EVENTS_ENABLED on the server-side SDK. Any
|
|
15
|
+
* unrecognized value keeps events on so a malformed setting cannot silently disable
|
|
16
|
+
* reporting for a real deployment.
|
|
17
|
+
*/
|
|
18
|
+
export function shouldSendLaunchDarklyEvents(value: LaunchDarklySendEventsSetting): boolean {
|
|
19
|
+
if (typeof value === 'boolean') {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
return value?.trim().toLowerCase() !== 'false';
|
|
23
|
+
}
|