@securitychecks/cli 0.1.1-rc.1
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/LICENSE +87 -0
- package/README.md +207 -0
- package/bin/scheck.js +17 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7254 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +908 -0
- package/dist/lib.js +2187 -0
- package/dist/lib.js.map +1 -0
- package/package.json +67 -0
package/dist/lib.js
ADDED
|
@@ -0,0 +1,2187 @@
|
|
|
1
|
+
import { resolveTargetPath, collect, ARTIFACT_SCHEMA_VERSION } from '@securitychecks/collector';
|
|
2
|
+
import { readFile, mkdir, writeFile } from 'fs/promises';
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join, dirname } from 'path';
|
|
6
|
+
import { gzipSync } from 'zlib';
|
|
7
|
+
import { randomUUID, createHash } from 'crypto';
|
|
8
|
+
|
|
9
|
+
// src/audit.ts
|
|
10
|
+
var CONFIG_DIR = join(homedir(), ".securitychecks");
|
|
11
|
+
join(CONFIG_DIR, "config.json");
|
|
12
|
+
function normalizeApiBaseUrl(input) {
|
|
13
|
+
const value = input.trim();
|
|
14
|
+
if (!value) {
|
|
15
|
+
throw new Error("API URL is empty");
|
|
16
|
+
}
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(value);
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error(`Invalid API URL: ${input}`);
|
|
22
|
+
}
|
|
23
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
24
|
+
throw new Error("API URL must start with http:// or https://");
|
|
25
|
+
}
|
|
26
|
+
if (url.username || url.password) {
|
|
27
|
+
throw new Error("API URL must not include credentials");
|
|
28
|
+
}
|
|
29
|
+
url.hash = "";
|
|
30
|
+
url.search = "";
|
|
31
|
+
const pathname = url.pathname.replace(/\/+$/, "");
|
|
32
|
+
const stripped = pathname.replace(/\/api\/v1$/i, "").replace(/\/v1$/i, "");
|
|
33
|
+
url.pathname = stripped.length === 0 ? "/" : `${stripped}/`;
|
|
34
|
+
return url.toString().replace(/\/$/, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/lib/license.ts
|
|
38
|
+
var CLOUD_API_KEY_ENV_VARS = [
|
|
39
|
+
"SECURITYCHECKS_API_KEY",
|
|
40
|
+
"SECURITYCHECKS_LICENSE_KEY"
|
|
41
|
+
];
|
|
42
|
+
var DEFAULT_CLOUD_BASE_URL = "https://api.securitychecks.ai";
|
|
43
|
+
function getCloudApiKey() {
|
|
44
|
+
for (const envVar of CLOUD_API_KEY_ENV_VARS) {
|
|
45
|
+
const value = process.env[envVar];
|
|
46
|
+
if (value) {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return void 0;
|
|
51
|
+
}
|
|
52
|
+
function getCloudApiBaseUrl() {
|
|
53
|
+
const raw = process.env["SECURITYCHECKS_API_URL"] ?? DEFAULT_CLOUD_BASE_URL;
|
|
54
|
+
return normalizeApiBaseUrl(raw);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/lib/errors.ts
|
|
58
|
+
var ErrorCodes = {
|
|
59
|
+
// CONFIG errors (001-099)
|
|
60
|
+
CONFIG_NOT_FOUND: "SC_CONFIG_001",
|
|
61
|
+
CONFIG_INVALID: "SC_CONFIG_002",
|
|
62
|
+
CONFIG_SCHEMA_ERROR: "SC_CONFIG_003",
|
|
63
|
+
// PARSE errors (100-199)
|
|
64
|
+
PARSE_TYPESCRIPT_ERROR: "SC_PARSE_101",
|
|
65
|
+
PARSE_FILE_NOT_FOUND: "SC_PARSE_102",
|
|
66
|
+
PARSE_UNSUPPORTED_SYNTAX: "SC_PARSE_103",
|
|
67
|
+
// CHECK errors (200-299)
|
|
68
|
+
CHECK_EXECUTION_ERROR: "SC_CHECK_201",
|
|
69
|
+
CHECK_TIMEOUT: "SC_CHECK_202",
|
|
70
|
+
CHECK_INVARIANT_NOT_FOUND: "SC_CHECK_203",
|
|
71
|
+
// IO errors (300-399)
|
|
72
|
+
IO_READ_ERROR: "SC_IO_301",
|
|
73
|
+
IO_WRITE_ERROR: "SC_IO_302",
|
|
74
|
+
IO_PERMISSION_DENIED: "SC_IO_303",
|
|
75
|
+
IO_PATH_NOT_FOUND: "SC_IO_304",
|
|
76
|
+
// CLI errors (400-499)
|
|
77
|
+
CLI_INVALID_ARGUMENT: "SC_CLI_401",
|
|
78
|
+
CLI_MISSING_ARGUMENT: "SC_CLI_402",
|
|
79
|
+
CLI_UNKNOWN_COMMAND: "SC_CLI_403",
|
|
80
|
+
// ARTIFACT errors (500-599)
|
|
81
|
+
ARTIFACT_NOT_FOUND: "SC_ARTIFACT_501",
|
|
82
|
+
ARTIFACT_INVALID: "SC_ARTIFACT_502",
|
|
83
|
+
ARTIFACT_VERSION_MISMATCH: "SC_ARTIFACT_503",
|
|
84
|
+
// CLOUD errors (600-699)
|
|
85
|
+
CLOUD_AUTH_FAILED: "SC_CLOUD_601",
|
|
86
|
+
CLOUD_PERMISSION_DENIED: "SC_CLOUD_602",
|
|
87
|
+
CLOUD_NOT_FOUND: "SC_CLOUD_603",
|
|
88
|
+
CLOUD_RATE_LIMITED: "SC_CLOUD_604",
|
|
89
|
+
CLOUD_API_ERROR: "SC_CLOUD_605",
|
|
90
|
+
CLOUD_NETWORK_ERROR: "SC_CLOUD_606",
|
|
91
|
+
CLOUD_INVALID_API_KEY: "SC_CLOUD_607",
|
|
92
|
+
AUTH_REQUIRED: "SC_CLOUD_608",
|
|
93
|
+
OFFLINE_NOT_SUPPORTED: "SC_CLOUD_609"
|
|
94
|
+
};
|
|
95
|
+
var ErrorMessages = {
|
|
96
|
+
[ErrorCodes.CONFIG_NOT_FOUND]: "Configuration file not found",
|
|
97
|
+
[ErrorCodes.CONFIG_INVALID]: "Configuration file is invalid",
|
|
98
|
+
[ErrorCodes.CONFIG_SCHEMA_ERROR]: "Configuration does not match expected schema",
|
|
99
|
+
[ErrorCodes.PARSE_TYPESCRIPT_ERROR]: "Failed to parse TypeScript file",
|
|
100
|
+
[ErrorCodes.PARSE_FILE_NOT_FOUND]: "Source file not found",
|
|
101
|
+
[ErrorCodes.PARSE_UNSUPPORTED_SYNTAX]: "Unsupported syntax encountered",
|
|
102
|
+
[ErrorCodes.CHECK_EXECUTION_ERROR]: "Error executing invariant check",
|
|
103
|
+
[ErrorCodes.CHECK_TIMEOUT]: "Invariant check timed out",
|
|
104
|
+
[ErrorCodes.CHECK_INVARIANT_NOT_FOUND]: "Invariant not found",
|
|
105
|
+
[ErrorCodes.IO_READ_ERROR]: "Failed to read file",
|
|
106
|
+
[ErrorCodes.IO_WRITE_ERROR]: "Failed to write file",
|
|
107
|
+
[ErrorCodes.IO_PERMISSION_DENIED]: "Permission denied",
|
|
108
|
+
[ErrorCodes.IO_PATH_NOT_FOUND]: "Path not found",
|
|
109
|
+
[ErrorCodes.CLI_INVALID_ARGUMENT]: "Invalid argument provided",
|
|
110
|
+
[ErrorCodes.CLI_MISSING_ARGUMENT]: "Required argument missing",
|
|
111
|
+
[ErrorCodes.CLI_UNKNOWN_COMMAND]: "Unknown command",
|
|
112
|
+
[ErrorCodes.ARTIFACT_NOT_FOUND]: "Artifact file not found",
|
|
113
|
+
[ErrorCodes.ARTIFACT_INVALID]: "Invalid artifact format",
|
|
114
|
+
[ErrorCodes.ARTIFACT_VERSION_MISMATCH]: "Artifact version not supported",
|
|
115
|
+
[ErrorCodes.CLOUD_AUTH_FAILED]: "Authentication failed",
|
|
116
|
+
[ErrorCodes.CLOUD_PERMISSION_DENIED]: "Permission denied",
|
|
117
|
+
[ErrorCodes.CLOUD_NOT_FOUND]: "Resource not found",
|
|
118
|
+
[ErrorCodes.CLOUD_RATE_LIMITED]: "Rate limit exceeded",
|
|
119
|
+
[ErrorCodes.CLOUD_API_ERROR]: "Cloud API error",
|
|
120
|
+
[ErrorCodes.CLOUD_NETWORK_ERROR]: "Network error",
|
|
121
|
+
[ErrorCodes.CLOUD_INVALID_API_KEY]: "Invalid API key format",
|
|
122
|
+
[ErrorCodes.AUTH_REQUIRED]: "API key required for evaluation",
|
|
123
|
+
[ErrorCodes.OFFLINE_NOT_SUPPORTED]: "Offline mode is not supported"
|
|
124
|
+
};
|
|
125
|
+
var ErrorRemediation = {
|
|
126
|
+
// CONFIG errors
|
|
127
|
+
[ErrorCodes.CONFIG_NOT_FOUND]: `
|
|
128
|
+
Create a configuration file in your project root:
|
|
129
|
+
|
|
130
|
+
scheck init
|
|
131
|
+
|
|
132
|
+
Or create securitychecks.config.ts manually:
|
|
133
|
+
|
|
134
|
+
export default {
|
|
135
|
+
include: ['src/**/*.ts'],
|
|
136
|
+
exclude: ['node_modules/**'],
|
|
137
|
+
};
|
|
138
|
+
`.trim(),
|
|
139
|
+
[ErrorCodes.CONFIG_INVALID]: `
|
|
140
|
+
Check your securitychecks.config.ts for syntax errors.
|
|
141
|
+
|
|
142
|
+
Common issues:
|
|
143
|
+
- Missing export default
|
|
144
|
+
- Invalid JSON in securitychecks.json
|
|
145
|
+
- Typo in configuration keys
|
|
146
|
+
|
|
147
|
+
Run with --verbose for more details.
|
|
148
|
+
`.trim(),
|
|
149
|
+
[ErrorCodes.CONFIG_SCHEMA_ERROR]: `
|
|
150
|
+
Your configuration has invalid options. Check these common issues:
|
|
151
|
+
|
|
152
|
+
- 'include' and 'exclude' must be arrays of glob patterns
|
|
153
|
+
- 'testPatterns' must be an array of test file patterns
|
|
154
|
+
- 'servicePatterns' must be an array of service file patterns
|
|
155
|
+
|
|
156
|
+
See: https://securitychecks.ai/docs/configuration
|
|
157
|
+
`.trim(),
|
|
158
|
+
// PARSE errors
|
|
159
|
+
[ErrorCodes.PARSE_TYPESCRIPT_ERROR]: `
|
|
160
|
+
A TypeScript file failed to parse. This usually means:
|
|
161
|
+
|
|
162
|
+
1. The file has syntax errors - run tsc to check
|
|
163
|
+
2. The file uses unsupported TypeScript features
|
|
164
|
+
3. There are missing dependencies
|
|
165
|
+
|
|
166
|
+
Try:
|
|
167
|
+
npx tsc --noEmit
|
|
168
|
+
|
|
169
|
+
If the error persists, exclude the problematic file:
|
|
170
|
+
exclude: ['path/to/problematic-file.ts']
|
|
171
|
+
`.trim(),
|
|
172
|
+
[ErrorCodes.PARSE_FILE_NOT_FOUND]: `
|
|
173
|
+
The specified source file doesn't exist. Check:
|
|
174
|
+
|
|
175
|
+
1. The file path is correct
|
|
176
|
+
2. The file hasn't been moved or deleted
|
|
177
|
+
3. Your include/exclude patterns are correct
|
|
178
|
+
|
|
179
|
+
Run: ls <path> to verify the file exists.
|
|
180
|
+
`.trim(),
|
|
181
|
+
[ErrorCodes.PARSE_UNSUPPORTED_SYNTAX]: `
|
|
182
|
+
The file contains syntax that can't be parsed. This may happen with:
|
|
183
|
+
|
|
184
|
+
- Very new TypeScript/JavaScript features
|
|
185
|
+
- Non-standard syntax extensions
|
|
186
|
+
- Malformed source code
|
|
187
|
+
|
|
188
|
+
Try excluding the file or updating the parser.
|
|
189
|
+
`.trim(),
|
|
190
|
+
// CHECK errors
|
|
191
|
+
[ErrorCodes.CHECK_EXECUTION_ERROR]: `
|
|
192
|
+
An invariant check failed to run. This is usually a bug in scheck.
|
|
193
|
+
|
|
194
|
+
Please report this issue with:
|
|
195
|
+
1. The full error message (--verbose)
|
|
196
|
+
2. A minimal reproduction
|
|
197
|
+
3. Your Node.js and @securitychecks/cli versions
|
|
198
|
+
|
|
199
|
+
Report at: https://github.com/securitychecks/securitychecks.ai/issues
|
|
200
|
+
`.trim(),
|
|
201
|
+
[ErrorCodes.CHECK_TIMEOUT]: `
|
|
202
|
+
An invariant check took too long. This can happen with:
|
|
203
|
+
|
|
204
|
+
1. Very large codebases
|
|
205
|
+
2. Complex file structures
|
|
206
|
+
3. Slow file system access
|
|
207
|
+
|
|
208
|
+
Try:
|
|
209
|
+
- Narrowing include patterns to scan fewer files
|
|
210
|
+
- Excluding large generated files
|
|
211
|
+
- Running with --only to check specific invariants
|
|
212
|
+
`.trim(),
|
|
213
|
+
[ErrorCodes.CHECK_INVARIANT_NOT_FOUND]: `
|
|
214
|
+
The specified invariant ID doesn't exist.
|
|
215
|
+
|
|
216
|
+
List available invariants:
|
|
217
|
+
scheck explain --list
|
|
218
|
+
|
|
219
|
+
Common invariant IDs:
|
|
220
|
+
- AUTHZ.SERVICE_LAYER.ENFORCED
|
|
221
|
+
- WEBHOOK.IDEMPOTENT
|
|
222
|
+
- TRANSACTION.POST_COMMIT.SIDE_EFFECTS
|
|
223
|
+
`.trim(),
|
|
224
|
+
// IO errors
|
|
225
|
+
[ErrorCodes.IO_READ_ERROR]: `
|
|
226
|
+
Failed to read a file. Check:
|
|
227
|
+
|
|
228
|
+
1. The file exists and is readable
|
|
229
|
+
2. You have permission to read the file
|
|
230
|
+
3. The file is not locked by another process
|
|
231
|
+
|
|
232
|
+
Try: cat <file> to verify readability.
|
|
233
|
+
`.trim(),
|
|
234
|
+
[ErrorCodes.IO_WRITE_ERROR]: `
|
|
235
|
+
Failed to write a file. Check:
|
|
236
|
+
|
|
237
|
+
1. The directory exists
|
|
238
|
+
2. You have write permission
|
|
239
|
+
3. There's enough disk space
|
|
240
|
+
4. The file is not locked
|
|
241
|
+
|
|
242
|
+
Try: touch <file> to verify writability.
|
|
243
|
+
`.trim(),
|
|
244
|
+
[ErrorCodes.IO_PERMISSION_DENIED]: `
|
|
245
|
+
Permission denied accessing a file or directory.
|
|
246
|
+
|
|
247
|
+
On Unix/Mac:
|
|
248
|
+
chmod +r <file> # Make readable
|
|
249
|
+
chmod +w <file> # Make writable
|
|
250
|
+
|
|
251
|
+
On Windows: Check file properties > Security tab.
|
|
252
|
+
`.trim(),
|
|
253
|
+
[ErrorCodes.IO_PATH_NOT_FOUND]: `
|
|
254
|
+
The specified path doesn't exist.
|
|
255
|
+
|
|
256
|
+
Check:
|
|
257
|
+
1. You're in the correct directory
|
|
258
|
+
2. The path is spelled correctly
|
|
259
|
+
3. The directory structure is correct
|
|
260
|
+
|
|
261
|
+
Run: pwd && ls to verify your location.
|
|
262
|
+
`.trim(),
|
|
263
|
+
// CLI errors
|
|
264
|
+
[ErrorCodes.CLI_INVALID_ARGUMENT]: `
|
|
265
|
+
Invalid command-line argument.
|
|
266
|
+
|
|
267
|
+
Run: scheck --help
|
|
268
|
+
|
|
269
|
+
Common commands:
|
|
270
|
+
scheck run # Run all checks
|
|
271
|
+
scheck run --ci # CI mode (fails on P0/P1)
|
|
272
|
+
scheck explain <id> # Explain an invariant
|
|
273
|
+
scheck init # Initialize configuration
|
|
274
|
+
`.trim(),
|
|
275
|
+
[ErrorCodes.CLI_MISSING_ARGUMENT]: `
|
|
276
|
+
A required argument is missing.
|
|
277
|
+
|
|
278
|
+
Check the command syntax:
|
|
279
|
+
scheck --help
|
|
280
|
+
scheck <command> --help
|
|
281
|
+
`.trim(),
|
|
282
|
+
[ErrorCodes.CLI_UNKNOWN_COMMAND]: `
|
|
283
|
+
Unknown command. Available commands:
|
|
284
|
+
|
|
285
|
+
run Run invariant checks
|
|
286
|
+
init Initialize configuration
|
|
287
|
+
explain Explain an invariant
|
|
288
|
+
baseline Manage baseline
|
|
289
|
+
waive Waive a finding
|
|
290
|
+
|
|
291
|
+
Run: scheck --help
|
|
292
|
+
`.trim(),
|
|
293
|
+
// ARTIFACT errors
|
|
294
|
+
[ErrorCodes.ARTIFACT_NOT_FOUND]: `
|
|
295
|
+
Artifact file not found.
|
|
296
|
+
|
|
297
|
+
The artifact file stores collected code facts. Options:
|
|
298
|
+
|
|
299
|
+
1. Let scheck collect automatically (default):
|
|
300
|
+
scheck run
|
|
301
|
+
|
|
302
|
+
2. Collect manually first:
|
|
303
|
+
npx scc collect -o .securitychecks/artifacts.json
|
|
304
|
+
scheck run --artifact .securitychecks/artifacts.json
|
|
305
|
+
|
|
306
|
+
3. Check if file was deleted:
|
|
307
|
+
ls .securitychecks/
|
|
308
|
+
`.trim(),
|
|
309
|
+
[ErrorCodes.ARTIFACT_INVALID]: `
|
|
310
|
+
The artifact file is malformed or corrupt.
|
|
311
|
+
|
|
312
|
+
Common issues:
|
|
313
|
+
- Incomplete JSON (process was killed during write)
|
|
314
|
+
- Modified manually with syntax errors
|
|
315
|
+
- Wrong file format
|
|
316
|
+
|
|
317
|
+
Fix:
|
|
318
|
+
1. Delete the corrupt artifact:
|
|
319
|
+
rm .securitychecks/artifacts.json
|
|
320
|
+
|
|
321
|
+
2. Re-collect:
|
|
322
|
+
scheck run
|
|
323
|
+
(or: npx scc collect -o .securitychecks/artifacts.json)
|
|
324
|
+
`.trim(),
|
|
325
|
+
[ErrorCodes.ARTIFACT_VERSION_MISMATCH]: `
|
|
326
|
+
The artifact was created by an incompatible version.
|
|
327
|
+
|
|
328
|
+
This happens when:
|
|
329
|
+
- Artifact was created by an older/newer scheck version
|
|
330
|
+
- Artifact schema has changed
|
|
331
|
+
|
|
332
|
+
Fix:
|
|
333
|
+
1. Delete the old artifact:
|
|
334
|
+
rm .securitychecks/artifacts.json
|
|
335
|
+
|
|
336
|
+
2. Re-collect with current version:
|
|
337
|
+
scheck run
|
|
338
|
+
|
|
339
|
+
Your current version: scheck --version
|
|
340
|
+
`.trim(),
|
|
341
|
+
// CLOUD errors
|
|
342
|
+
[ErrorCodes.CLOUD_AUTH_FAILED]: `
|
|
343
|
+
Authentication failed. Your API key may be invalid or expired.
|
|
344
|
+
|
|
345
|
+
Fix:
|
|
346
|
+
1. Generate a new API key at https://securitychecks.ai/dashboard/settings/api-keys
|
|
347
|
+
2. Log in again:
|
|
348
|
+
scheck login
|
|
349
|
+
|
|
350
|
+
Environment variable:
|
|
351
|
+
export SECURITYCHECKS_API_KEY=sc_live_...
|
|
352
|
+
`.trim(),
|
|
353
|
+
[ErrorCodes.CLOUD_PERMISSION_DENIED]: `
|
|
354
|
+
You don't have permission for this action.
|
|
355
|
+
|
|
356
|
+
Check:
|
|
357
|
+
1. You have access to the project/organization
|
|
358
|
+
2. Your API key has the required scopes
|
|
359
|
+
3. Your subscription is active
|
|
360
|
+
|
|
361
|
+
Manage at: https://securitychecks.ai/dashboard
|
|
362
|
+
`.trim(),
|
|
363
|
+
[ErrorCodes.CLOUD_NOT_FOUND]: `
|
|
364
|
+
The requested resource was not found.
|
|
365
|
+
|
|
366
|
+
Check:
|
|
367
|
+
1. The project slug is correct
|
|
368
|
+
2. The project exists and you have access
|
|
369
|
+
3. The resource ID is valid
|
|
370
|
+
|
|
371
|
+
List your projects:
|
|
372
|
+
scheck config --show
|
|
373
|
+
`.trim(),
|
|
374
|
+
[ErrorCodes.CLOUD_RATE_LIMITED]: `
|
|
375
|
+
You've hit the rate limit. Please try again later.
|
|
376
|
+
|
|
377
|
+
Options:
|
|
378
|
+
1. Wait a few minutes and retry
|
|
379
|
+
2. Upgrade your plan for higher limits
|
|
380
|
+
|
|
381
|
+
Plan limits: https://securitychecks.ai/pricing
|
|
382
|
+
`.trim(),
|
|
383
|
+
[ErrorCodes.CLOUD_API_ERROR]: `
|
|
384
|
+
The SecurityChecks API returned an error.
|
|
385
|
+
|
|
386
|
+
This could be:
|
|
387
|
+
1. A temporary service issue - try again shortly
|
|
388
|
+
2. An invalid request - check your parameters
|
|
389
|
+
|
|
390
|
+
Status: https://status.securitychecks.ai
|
|
391
|
+
Help: https://securitychecks.ai/docs/troubleshooting
|
|
392
|
+
`.trim(),
|
|
393
|
+
[ErrorCodes.CLOUD_NETWORK_ERROR]: `
|
|
394
|
+
Could not connect to SecurityChecks API.
|
|
395
|
+
|
|
396
|
+
Check:
|
|
397
|
+
1. Your internet connection
|
|
398
|
+
2. Firewall/proxy settings
|
|
399
|
+
3. API endpoint accessibility
|
|
400
|
+
|
|
401
|
+
Default API: https://api.securitychecks.ai
|
|
402
|
+
`.trim(),
|
|
403
|
+
[ErrorCodes.CLOUD_INVALID_API_KEY]: `
|
|
404
|
+
The API key format is invalid.
|
|
405
|
+
|
|
406
|
+
API keys should start with:
|
|
407
|
+
- sc_live_ for production
|
|
408
|
+
- sc_test_ for testing
|
|
409
|
+
|
|
410
|
+
Get a key at: https://securitychecks.ai/dashboard/settings/api-keys
|
|
411
|
+
`.trim(),
|
|
412
|
+
[ErrorCodes.AUTH_REQUIRED]: `
|
|
413
|
+
An API key is required to run security checks.
|
|
414
|
+
|
|
415
|
+
SecurityChecks uses cloud evaluation to protect proprietary patterns.
|
|
416
|
+
Your source code never leaves your machine - only structural facts are sent.
|
|
417
|
+
|
|
418
|
+
Setup:
|
|
419
|
+
1. Get your API key at https://securitychecks.ai/dashboard/settings/api-keys
|
|
420
|
+
2. Set environment variable:
|
|
421
|
+
export SECURITYCHECKS_API_KEY=sc_live_...
|
|
422
|
+
|
|
423
|
+
Or add to securitychecks.config.yaml:
|
|
424
|
+
calibration:
|
|
425
|
+
apiKey: sc_live_...
|
|
426
|
+
`.trim(),
|
|
427
|
+
[ErrorCodes.OFFLINE_NOT_SUPPORTED]: `
|
|
428
|
+
Offline mode is not supported.
|
|
429
|
+
|
|
430
|
+
SecurityChecks requires cloud evaluation to protect proprietary patterns.
|
|
431
|
+
Your source code never leaves your machine - only structural facts are sent.
|
|
432
|
+
|
|
433
|
+
Options:
|
|
434
|
+
1. Remove --offline flag and ensure network connectivity
|
|
435
|
+
2. For air-gapped environments, contact sales for an enterprise on-premise license:
|
|
436
|
+
https://securitychecks.ai/enterprise
|
|
437
|
+
`.trim()
|
|
438
|
+
};
|
|
439
|
+
var CLIError = class _CLIError extends Error {
|
|
440
|
+
code;
|
|
441
|
+
details;
|
|
442
|
+
cause;
|
|
443
|
+
constructor(code, message, options) {
|
|
444
|
+
const baseMessage = message ?? ErrorMessages[code];
|
|
445
|
+
super(baseMessage, { cause: options?.cause });
|
|
446
|
+
this.name = "CLIError";
|
|
447
|
+
this.code = code;
|
|
448
|
+
this.details = options?.details;
|
|
449
|
+
this.cause = options?.cause;
|
|
450
|
+
Error.captureStackTrace?.(this, _CLIError);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Get remediation guidance for this error
|
|
454
|
+
*/
|
|
455
|
+
getRemediation() {
|
|
456
|
+
return ErrorRemediation[this.code];
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Format error for user display
|
|
460
|
+
*/
|
|
461
|
+
toUserString(verbose = false) {
|
|
462
|
+
const parts = [`[${this.code}] ${this.message}`];
|
|
463
|
+
if (verbose && this.details) {
|
|
464
|
+
parts.push(`
|
|
465
|
+
Details: ${JSON.stringify(this.details, null, 2)}`);
|
|
466
|
+
}
|
|
467
|
+
if (verbose && this.cause) {
|
|
468
|
+
parts.push(`
|
|
469
|
+
Caused by: ${this.cause.message}`);
|
|
470
|
+
if (this.cause.stack) {
|
|
471
|
+
parts.push(`
|
|
472
|
+
${this.cause.stack}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return parts.join("");
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Format error with remediation for user display
|
|
479
|
+
*/
|
|
480
|
+
toUserStringWithRemediation() {
|
|
481
|
+
const parts = [this.toUserString()];
|
|
482
|
+
const remediation = this.getRemediation();
|
|
483
|
+
if (remediation) {
|
|
484
|
+
parts.push("\n\nHow to fix:\n");
|
|
485
|
+
const indented = remediation.split("\n").map((line) => ` ${line}`).join("\n");
|
|
486
|
+
parts.push(indented);
|
|
487
|
+
}
|
|
488
|
+
return parts.join("");
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Format error for JSON output
|
|
492
|
+
*/
|
|
493
|
+
toJSON() {
|
|
494
|
+
return {
|
|
495
|
+
code: this.code,
|
|
496
|
+
message: this.message,
|
|
497
|
+
remediation: this.getRemediation(),
|
|
498
|
+
details: this.details,
|
|
499
|
+
cause: this.cause ? {
|
|
500
|
+
message: this.cause.message,
|
|
501
|
+
stack: this.cause.stack
|
|
502
|
+
} : void 0
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
function isCLIError(error) {
|
|
507
|
+
return error instanceof CLIError;
|
|
508
|
+
}
|
|
509
|
+
function wrapError(error, code, message) {
|
|
510
|
+
if (error instanceof CLIError) {
|
|
511
|
+
return error;
|
|
512
|
+
}
|
|
513
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
514
|
+
return new CLIError(code, message, { cause });
|
|
515
|
+
}
|
|
516
|
+
function detectCIContext() {
|
|
517
|
+
if (process.env["GITHUB_ACTIONS"] === "true") {
|
|
518
|
+
return detectGitHubActions();
|
|
519
|
+
}
|
|
520
|
+
if (process.env["GITLAB_CI"] === "true") {
|
|
521
|
+
return detectGitLabCI();
|
|
522
|
+
}
|
|
523
|
+
if (process.env["CIRCLECI"] === "true") {
|
|
524
|
+
return detectCircleCI();
|
|
525
|
+
}
|
|
526
|
+
if (process.env["JENKINS_URL"]) {
|
|
527
|
+
return detectJenkins();
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
function detectGitHubActions() {
|
|
532
|
+
const eventName = process.env["GITHUB_EVENT_NAME"];
|
|
533
|
+
const isPullRequest = eventName === "pull_request" || eventName === "pull_request_target";
|
|
534
|
+
const eventPayload = readGitHubEventPayload();
|
|
535
|
+
let branch;
|
|
536
|
+
if (isPullRequest) {
|
|
537
|
+
branch = eventPayload?.pull_request?.head?.ref || process.env["GITHUB_HEAD_REF"];
|
|
538
|
+
} else {
|
|
539
|
+
const ref = process.env["GITHUB_REF"] || "";
|
|
540
|
+
branch = ref.replace(/^refs\/heads\//, "");
|
|
541
|
+
}
|
|
542
|
+
const commitSha = isPullRequest ? eventPayload?.pull_request?.head?.sha || process.env["GITHUB_SHA"] : process.env["GITHUB_SHA"];
|
|
543
|
+
let prNumber;
|
|
544
|
+
if (isPullRequest) {
|
|
545
|
+
prNumber = eventPayload?.number;
|
|
546
|
+
if (!prNumber) {
|
|
547
|
+
const prRef = process.env["GITHUB_REF"] || "";
|
|
548
|
+
const match = prRef.match(/refs\/pull\/(\d+)/);
|
|
549
|
+
if (match && match[1]) {
|
|
550
|
+
prNumber = parseInt(match[1], 10);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
provider: "github-actions",
|
|
556
|
+
branch,
|
|
557
|
+
commitSha,
|
|
558
|
+
prNumber,
|
|
559
|
+
repository: process.env["GITHUB_REPOSITORY"],
|
|
560
|
+
isPullRequest
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function readGitHubEventPayload() {
|
|
564
|
+
const eventPath = process.env["GITHUB_EVENT_PATH"];
|
|
565
|
+
if (!eventPath) {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
const raw = readFileSync(eventPath, "utf8");
|
|
570
|
+
return JSON.parse(raw);
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function detectGitLabCI() {
|
|
576
|
+
const mrIid = process.env["CI_MERGE_REQUEST_IID"];
|
|
577
|
+
const isPullRequest = !!mrIid;
|
|
578
|
+
let prNumber;
|
|
579
|
+
if (mrIid) {
|
|
580
|
+
prNumber = parseInt(mrIid, 10);
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
provider: "gitlab-ci",
|
|
584
|
+
branch: process.env["CI_COMMIT_REF_NAME"],
|
|
585
|
+
commitSha: process.env["CI_COMMIT_SHA"],
|
|
586
|
+
prNumber,
|
|
587
|
+
repository: process.env["CI_PROJECT_PATH"],
|
|
588
|
+
isPullRequest
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
function detectCircleCI() {
|
|
592
|
+
let prNumber;
|
|
593
|
+
const prUrl = process.env["CIRCLE_PULL_REQUEST"];
|
|
594
|
+
if (prUrl) {
|
|
595
|
+
const match = prUrl.match(/\/pull\/(\d+)/);
|
|
596
|
+
if (match && match[1]) {
|
|
597
|
+
prNumber = parseInt(match[1], 10);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
provider: "circleci",
|
|
602
|
+
branch: process.env["CIRCLE_BRANCH"],
|
|
603
|
+
commitSha: process.env["CIRCLE_SHA1"],
|
|
604
|
+
prNumber,
|
|
605
|
+
repository: `${process.env["CIRCLE_PROJECT_USERNAME"]}/${process.env["CIRCLE_PROJECT_REPONAME"]}`,
|
|
606
|
+
isPullRequest: !!prUrl
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function detectJenkins() {
|
|
610
|
+
const changeId = process.env["CHANGE_ID"];
|
|
611
|
+
const isPullRequest = !!changeId;
|
|
612
|
+
let prNumber;
|
|
613
|
+
if (changeId) {
|
|
614
|
+
prNumber = parseInt(changeId, 10);
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
provider: "jenkins",
|
|
618
|
+
branch: process.env["BRANCH_NAME"] || process.env["GIT_BRANCH"],
|
|
619
|
+
commitSha: process.env["GIT_COMMIT"],
|
|
620
|
+
prNumber,
|
|
621
|
+
isPullRequest
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function buildHeaders(extra = {}) {
|
|
625
|
+
const headers = { ...extra };
|
|
626
|
+
const bypassSecret = process.env["VERCEL_AUTOMATION_BYPASS_SECRET"];
|
|
627
|
+
if (bypassSecret) {
|
|
628
|
+
headers["x-vercel-protection-bypass"] = bypassSecret;
|
|
629
|
+
}
|
|
630
|
+
return headers;
|
|
631
|
+
}
|
|
632
|
+
function isTruthy(value) {
|
|
633
|
+
if (!value) return false;
|
|
634
|
+
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
|
635
|
+
}
|
|
636
|
+
function isCloudEvalAvailable(apiKey) {
|
|
637
|
+
return !!apiKey;
|
|
638
|
+
}
|
|
639
|
+
function buildEvaluatePayload(artifact, options) {
|
|
640
|
+
const ciContext = options.ciContext !== void 0 ? options.ciContext : detectCIContext();
|
|
641
|
+
return {
|
|
642
|
+
artifact: {
|
|
643
|
+
version: artifact.version,
|
|
644
|
+
schemaVersion: artifact.schemaVersion,
|
|
645
|
+
profile: artifact.profile,
|
|
646
|
+
extractedAt: artifact.extractedAt,
|
|
647
|
+
targetPath: artifact.codebase?.root,
|
|
648
|
+
codebase: {
|
|
649
|
+
file_count: artifact.codebase?.filesScanned ?? 0,
|
|
650
|
+
languages: artifact.codebase?.languages ?? []
|
|
651
|
+
},
|
|
652
|
+
services: artifact.services,
|
|
653
|
+
authzCalls: artifact.authzCalls ?? [],
|
|
654
|
+
cacheOperations: artifact.cacheOperations ?? [],
|
|
655
|
+
transactionScopes: artifact.transactionScopes ?? [],
|
|
656
|
+
webhookHandlers: artifact.webhookHandlers ?? [],
|
|
657
|
+
jobHandlers: artifact.jobHandlers ?? [],
|
|
658
|
+
membershipMutations: artifact.membershipMutations ?? [],
|
|
659
|
+
tests: artifact.tests ?? [],
|
|
660
|
+
routes: artifact.routes ?? [],
|
|
661
|
+
callGraph: artifact.callGraph,
|
|
662
|
+
dataFlow: artifact.dataFlows?.flows ?? [],
|
|
663
|
+
rlsPolicies: artifact.rlsArtifact?.rlsPolicies ?? []
|
|
664
|
+
},
|
|
665
|
+
options: {
|
|
666
|
+
invariants: options.invariants,
|
|
667
|
+
skip: options.skip,
|
|
668
|
+
severity: options.severity,
|
|
669
|
+
projectSlug: options.projectSlug,
|
|
670
|
+
// CI context for scan association (enables PR comments)
|
|
671
|
+
branch: ciContext?.branch,
|
|
672
|
+
commitSha: ciContext?.commitSha,
|
|
673
|
+
prNumber: ciContext?.prNumber,
|
|
674
|
+
// Opt-in guard for GitHub PR CI mode to enforce webhook + evaluate env alignment.
|
|
675
|
+
// This is intentionally gated to avoid breaking standalone CLI workflows that do not use GitHub App webhooks.
|
|
676
|
+
requireExistingCiScan: ciContext?.provider === "github-actions" && ciContext?.isPullRequest && isTruthy(process.env["SECURITYCHECKS_REQUIRE_EXISTING_CI_SCAN"]) ? true : void 0
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
async function submitForEvaluation(artifact, options) {
|
|
681
|
+
const endpoint = `${options.baseUrl}/api/v1/evaluate`;
|
|
682
|
+
const payload = buildEvaluatePayload(artifact, options);
|
|
683
|
+
const json = JSON.stringify(payload);
|
|
684
|
+
const gz = gzipSync(Buffer.from(json, "utf8"));
|
|
685
|
+
const response = await fetch(endpoint, {
|
|
686
|
+
method: "POST",
|
|
687
|
+
headers: buildHeaders({
|
|
688
|
+
"Content-Type": "application/json",
|
|
689
|
+
"Content-Encoding": "gzip",
|
|
690
|
+
Authorization: `Bearer ${options.apiKey}`
|
|
691
|
+
}),
|
|
692
|
+
body: gz
|
|
693
|
+
});
|
|
694
|
+
if (!response.ok) {
|
|
695
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
696
|
+
const serverMessage = errorBody?.message;
|
|
697
|
+
const errorText = serverMessage ? `${errorBody.error || "Cloud API error"}: ${serverMessage}` : errorBody.error;
|
|
698
|
+
if (response.status === 401) {
|
|
699
|
+
throw new CLIError(ErrorCodes.CLOUD_AUTH_FAILED, "Invalid API key. Check your SECURITYCHECKS_API_KEY.");
|
|
700
|
+
}
|
|
701
|
+
if (response.status === 413) {
|
|
702
|
+
throw new CLIError(
|
|
703
|
+
ErrorCodes.CLOUD_API_ERROR,
|
|
704
|
+
`Artifact too large. ${errorBody.details || "Contact support if this persists."}`,
|
|
705
|
+
{ details: errorBody.details }
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
if (response.status === 429) {
|
|
709
|
+
const remaining = errorBody.usage?.scansRemaining ?? 0;
|
|
710
|
+
throw new CLIError(
|
|
711
|
+
ErrorCodes.CLOUD_RATE_LIMITED,
|
|
712
|
+
`Monthly scan limit reached (${remaining} remaining). Upgrade at https://securitychecks.ai/pricing`,
|
|
713
|
+
{ details: { scansRemaining: remaining } }
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
if (response.status === 503) {
|
|
717
|
+
throw new CLIError(ErrorCodes.CLOUD_API_ERROR, "Cloud evaluation temporarily unavailable. Try again later.");
|
|
718
|
+
}
|
|
719
|
+
throw new CLIError(
|
|
720
|
+
ErrorCodes.CLOUD_API_ERROR,
|
|
721
|
+
errorText || `Cloud API error: ${response.status}`,
|
|
722
|
+
{ details: errorBody }
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return await response.json();
|
|
726
|
+
}
|
|
727
|
+
async function pollForResults(scanId, options) {
|
|
728
|
+
const timeout = options.timeout ?? 3e5;
|
|
729
|
+
const pollInterval = options.pollInterval ?? 2e3;
|
|
730
|
+
const startTime = Date.now();
|
|
731
|
+
while (Date.now() - startTime < timeout) {
|
|
732
|
+
const response = await fetch(`${options.baseUrl}/api/v1/scans/${scanId}`, {
|
|
733
|
+
method: "GET",
|
|
734
|
+
headers: buildHeaders({
|
|
735
|
+
Authorization: `Bearer ${options.apiKey}`
|
|
736
|
+
})
|
|
737
|
+
});
|
|
738
|
+
if (!response.ok) {
|
|
739
|
+
if (response.status === 404) {
|
|
740
|
+
throw new CLIError(ErrorCodes.CLOUD_NOT_FOUND, `Scan ${scanId} not found`);
|
|
741
|
+
}
|
|
742
|
+
throw new CLIError(ErrorCodes.CLOUD_API_ERROR, `Failed to get scan status: ${response.status}`);
|
|
743
|
+
}
|
|
744
|
+
const scan = await response.json();
|
|
745
|
+
options.onProgress?.({
|
|
746
|
+
status: scan.status,
|
|
747
|
+
message: scan.status === "RUNNING" ? "Evaluating..." : void 0
|
|
748
|
+
});
|
|
749
|
+
if (scan.status === "COMPLETED") {
|
|
750
|
+
return {
|
|
751
|
+
findings: scan.findings ?? [],
|
|
752
|
+
stats: scan.stats ?? {
|
|
753
|
+
invariantsRun: 0,
|
|
754
|
+
patternsRun: 0,
|
|
755
|
+
findingsCount: scan.findings?.length ?? 0,
|
|
756
|
+
executionMs: Date.now() - startTime
|
|
757
|
+
},
|
|
758
|
+
usage: scan.usage ?? { scansUsed: 0, scansRemaining: 0 }
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
if (scan.status === "FAILED") {
|
|
762
|
+
throw new CLIError(
|
|
763
|
+
ErrorCodes.CLOUD_API_ERROR,
|
|
764
|
+
`Scan failed: ${scan.errorMessage ?? "Unknown error"}`,
|
|
765
|
+
{ details: { scanId, errorMessage: scan.errorMessage } }
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
if (scan.status === "CANCELLED") {
|
|
769
|
+
throw new CLIError(ErrorCodes.CLOUD_API_ERROR, "Scan was cancelled", { details: { scanId } });
|
|
770
|
+
}
|
|
771
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
772
|
+
}
|
|
773
|
+
throw new CLIError(
|
|
774
|
+
ErrorCodes.CHECK_TIMEOUT,
|
|
775
|
+
`Scan timed out after ${timeout / 1e3}s. Check dashboard for results.`,
|
|
776
|
+
{ details: { timeoutMs: timeout, scanId } }
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
async function evaluateCloud(artifact, options) {
|
|
780
|
+
const { scanId } = await submitForEvaluation(artifact, options);
|
|
781
|
+
options.onProgress?.({
|
|
782
|
+
status: "PENDING",
|
|
783
|
+
message: "Submitted for evaluation..."
|
|
784
|
+
});
|
|
785
|
+
return pollForResults(scanId, options);
|
|
786
|
+
}
|
|
787
|
+
async function checkCloudHealth(baseUrl) {
|
|
788
|
+
try {
|
|
789
|
+
const response = await fetch(`${baseUrl}/api/health`, {
|
|
790
|
+
method: "GET",
|
|
791
|
+
headers: buildHeaders(),
|
|
792
|
+
signal: AbortSignal.timeout(5e3)
|
|
793
|
+
});
|
|
794
|
+
return response.ok;
|
|
795
|
+
} catch {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
async function getCloudInvariants(baseUrl, apiKey) {
|
|
800
|
+
const response = await fetch(`${baseUrl}/api/v1/evaluate`, {
|
|
801
|
+
method: "GET",
|
|
802
|
+
headers: buildHeaders({
|
|
803
|
+
Authorization: `Bearer ${apiKey}`
|
|
804
|
+
})
|
|
805
|
+
});
|
|
806
|
+
if (!response.ok) {
|
|
807
|
+
throw new CLIError(ErrorCodes.CLOUD_API_ERROR, `Failed to fetch invariants: ${response.status}`);
|
|
808
|
+
}
|
|
809
|
+
const data = await response.json();
|
|
810
|
+
return data.invariants;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/lib/project-slug.ts
|
|
814
|
+
function getProjectSlug(env = process.env) {
|
|
815
|
+
const raw = env["SECURITYCHECKS_PROJECT"];
|
|
816
|
+
const trimmed = raw?.trim();
|
|
817
|
+
return trimmed ? trimmed : void 0;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/audit.ts
|
|
821
|
+
function toArtifact(collectorArtifact) {
|
|
822
|
+
return {
|
|
823
|
+
version: "1.0",
|
|
824
|
+
extractedAt: collectorArtifact.extractedAt,
|
|
825
|
+
targetPath: collectorArtifact.codebase.root,
|
|
826
|
+
services: collectorArtifact.services,
|
|
827
|
+
authzCalls: collectorArtifact.authzCalls ?? [],
|
|
828
|
+
cacheOperations: collectorArtifact.cacheOperations ?? [],
|
|
829
|
+
transactionScopes: collectorArtifact.transactionScopes ?? [],
|
|
830
|
+
webhookHandlers: collectorArtifact.webhookHandlers ?? [],
|
|
831
|
+
jobHandlers: collectorArtifact.jobHandlers ?? [],
|
|
832
|
+
membershipMutations: collectorArtifact.membershipMutations ?? [],
|
|
833
|
+
tests: collectorArtifact.tests ?? [],
|
|
834
|
+
routes: collectorArtifact.routes ?? [],
|
|
835
|
+
dataFlows: collectorArtifact.dataFlows,
|
|
836
|
+
callGraph: collectorArtifact.callGraph
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
async function audit(options = {}) {
|
|
840
|
+
const startTime = Date.now();
|
|
841
|
+
const apiKey = getCloudApiKey();
|
|
842
|
+
if (!apiKey) {
|
|
843
|
+
throw new CLIError(
|
|
844
|
+
ErrorCodes.AUTH_REQUIRED,
|
|
845
|
+
"API key required for scanning",
|
|
846
|
+
{
|
|
847
|
+
details: {
|
|
848
|
+
remediation: `Set SECURITYCHECKS_API_KEY environment variable.
|
|
849
|
+
Get your API key at https://securitychecks.ai/dashboard/settings/api-keys
|
|
850
|
+
|
|
851
|
+
For air-gapped environments, contact sales@securitychecks.ai for enterprise options.`
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
const targetPath = resolveTargetPath(options.targetPath);
|
|
857
|
+
const collectorArtifact = await collect({
|
|
858
|
+
targetPath,
|
|
859
|
+
profile: "securitychecks"
|
|
860
|
+
});
|
|
861
|
+
const cloudResult = await evaluateCloud(collectorArtifact, {
|
|
862
|
+
apiKey,
|
|
863
|
+
baseUrl: getCloudApiBaseUrl(),
|
|
864
|
+
invariants: options.only,
|
|
865
|
+
skip: options.skip,
|
|
866
|
+
projectSlug: getProjectSlug()
|
|
867
|
+
});
|
|
868
|
+
const artifact = toArtifact(collectorArtifact);
|
|
869
|
+
const findings = cloudResult.findings;
|
|
870
|
+
const byPriority = {
|
|
871
|
+
P0: findings.filter((f) => f.severity === "P0").length,
|
|
872
|
+
P1: findings.filter((f) => f.severity === "P1").length,
|
|
873
|
+
P2: findings.filter((f) => f.severity === "P2").length
|
|
874
|
+
};
|
|
875
|
+
const invariantIds = [...new Set(findings.map((f) => f.invariantId))];
|
|
876
|
+
const results = invariantIds.map((id) => ({
|
|
877
|
+
invariantId: id,
|
|
878
|
+
passed: !findings.some((f) => f.invariantId === id),
|
|
879
|
+
findings: findings.filter((f) => f.invariantId === id),
|
|
880
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
881
|
+
duration: 0
|
|
882
|
+
}));
|
|
883
|
+
const passed = results.filter((r) => r.passed).length;
|
|
884
|
+
const failed = results.filter((r) => !r.passed).length;
|
|
885
|
+
const waived = findings.filter((f) => f.waived).length;
|
|
886
|
+
return {
|
|
887
|
+
version: "1.0",
|
|
888
|
+
targetPath,
|
|
889
|
+
runAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
890
|
+
duration: Date.now() - startTime,
|
|
891
|
+
summary: {
|
|
892
|
+
total: cloudResult.stats.invariantsRun,
|
|
893
|
+
passed,
|
|
894
|
+
failed,
|
|
895
|
+
waived,
|
|
896
|
+
byPriority
|
|
897
|
+
},
|
|
898
|
+
results,
|
|
899
|
+
artifact
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
var SUPPORTED_SCHEMA_RANGE = {
|
|
903
|
+
// Minimum version we can consume
|
|
904
|
+
minMajor: 1,
|
|
905
|
+
minMinor: 0,
|
|
906
|
+
// Maximum major version we understand (breaking changes)
|
|
907
|
+
maxMajor: 1
|
|
908
|
+
};
|
|
909
|
+
function parseSemver(version) {
|
|
910
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
911
|
+
if (!match || !match[1] || !match[2] || !match[3]) return null;
|
|
912
|
+
return {
|
|
913
|
+
major: parseInt(match[1], 10),
|
|
914
|
+
minor: parseInt(match[2], 10),
|
|
915
|
+
patch: parseInt(match[3], 10)
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
function validateSchemaVersion(artifactSchemaVersion) {
|
|
919
|
+
const currentVersion = ARTIFACT_SCHEMA_VERSION;
|
|
920
|
+
const effectiveVersion = artifactSchemaVersion ?? "1.0.0";
|
|
921
|
+
const parsed = parseSemver(effectiveVersion);
|
|
922
|
+
if (!parsed) {
|
|
923
|
+
return {
|
|
924
|
+
valid: false,
|
|
925
|
+
artifactVersion: effectiveVersion,
|
|
926
|
+
currentVersion,
|
|
927
|
+
error: `Invalid schema version format: "${effectiveVersion}" (expected semver like "1.0.0")`,
|
|
928
|
+
remediation: "Re-collect artifacts with: npx scc collect -o .securitychecks/artifacts.json"
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
const { major, minor } = parsed;
|
|
932
|
+
if (major > SUPPORTED_SCHEMA_RANGE.maxMajor) {
|
|
933
|
+
return {
|
|
934
|
+
valid: false,
|
|
935
|
+
artifactVersion: effectiveVersion,
|
|
936
|
+
currentVersion,
|
|
937
|
+
error: `Artifact schema version ${effectiveVersion} is too new (CLI supports up to ${SUPPORTED_SCHEMA_RANGE.maxMajor}.x.x)`,
|
|
938
|
+
remediation: `Upgrade scheck: npm install -g @securitychecks/cli@latest`
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
if (major < SUPPORTED_SCHEMA_RANGE.minMajor) {
|
|
942
|
+
return {
|
|
943
|
+
valid: false,
|
|
944
|
+
artifactVersion: effectiveVersion,
|
|
945
|
+
currentVersion,
|
|
946
|
+
error: `Artifact schema version ${effectiveVersion} is too old (CLI requires ${SUPPORTED_SCHEMA_RANGE.minMajor}.x.x+)`,
|
|
947
|
+
remediation: "Re-collect artifacts: npx scc collect -o .securitychecks/artifacts.json"
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
if (minor < SUPPORTED_SCHEMA_RANGE.minMinor) {
|
|
951
|
+
return {
|
|
952
|
+
valid: false,
|
|
953
|
+
artifactVersion: effectiveVersion,
|
|
954
|
+
currentVersion,
|
|
955
|
+
error: `Artifact schema version ${effectiveVersion} is missing required fields (CLI requires ${SUPPORTED_SCHEMA_RANGE.minMajor}.${SUPPORTED_SCHEMA_RANGE.minMinor}.x+)`,
|
|
956
|
+
remediation: "Re-collect artifacts: npx scc collect -o .securitychecks/artifacts.json"
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
valid: true,
|
|
961
|
+
artifactVersion: effectiveVersion,
|
|
962
|
+
currentVersion
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function getCurrentSchemaVersion() {
|
|
966
|
+
return ARTIFACT_SCHEMA_VERSION;
|
|
967
|
+
}
|
|
968
|
+
var ANCHOR_EXTRACTORS = {
|
|
969
|
+
// Webhook: include provider from context if available
|
|
970
|
+
"WEBHOOK.IDEMPOTENT": (finding) => {
|
|
971
|
+
const context = finding.evidence[0]?.context ?? "";
|
|
972
|
+
const providerMatch = context.match(/^(stripe|github|slack|svix|generic):/i);
|
|
973
|
+
return {
|
|
974
|
+
provider: providerMatch?.[1]?.toLowerCase() ?? ""
|
|
975
|
+
};
|
|
976
|
+
},
|
|
977
|
+
// Transaction: include side effect type from message
|
|
978
|
+
"TRANSACTION.POST_COMMIT.SIDE_EFFECTS": (finding) => {
|
|
979
|
+
const typeMatch = finding.message.match(/contains (\w+) side effect/i);
|
|
980
|
+
return {
|
|
981
|
+
sideEffectType: typeMatch?.[1]?.toLowerCase() ?? ""
|
|
982
|
+
};
|
|
983
|
+
},
|
|
984
|
+
// Membership revocation: include mutation type
|
|
985
|
+
"AUTHZ.MEMBERSHIP.REVOCATION.IMMEDIATE": (finding) => {
|
|
986
|
+
const context = finding.evidence[0]?.context ?? "";
|
|
987
|
+
const mutationMatch = context.match(/mutationType[:\s]+(\w+)/i);
|
|
988
|
+
return {
|
|
989
|
+
mutationType: mutationMatch?.[1]?.toLowerCase() ?? ""
|
|
990
|
+
};
|
|
991
|
+
},
|
|
992
|
+
// Keys revocation: include entity type
|
|
993
|
+
"AUTHZ.KEYS.REVOCATION.IMMEDIATE": (finding) => {
|
|
994
|
+
const context = finding.evidence[0]?.context ?? "";
|
|
995
|
+
const entityMatch = context.match(/entity[:\s]+(\w+)/i);
|
|
996
|
+
return {
|
|
997
|
+
entity: entityMatch?.[1]?.toLowerCase() ?? ""
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
function extractIdentityPayload(finding) {
|
|
1002
|
+
const primary = finding.evidence[0];
|
|
1003
|
+
const base = {
|
|
1004
|
+
invariantId: finding.invariantId.toLowerCase(),
|
|
1005
|
+
file: normalizePath(primary?.file ?? ""),
|
|
1006
|
+
symbol: (primary?.symbol ?? "").toLowerCase()
|
|
1007
|
+
};
|
|
1008
|
+
const extractor = ANCHOR_EXTRACTORS[finding.invariantId];
|
|
1009
|
+
if (extractor) {
|
|
1010
|
+
const anchors = extractor(finding);
|
|
1011
|
+
Object.assign(base, anchors);
|
|
1012
|
+
}
|
|
1013
|
+
return base;
|
|
1014
|
+
}
|
|
1015
|
+
function normalizePath(path) {
|
|
1016
|
+
return path.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase();
|
|
1017
|
+
}
|
|
1018
|
+
function hashPayload(payload) {
|
|
1019
|
+
const keys = Object.keys(payload).sort();
|
|
1020
|
+
const canonical = keys.map((k) => `${k}:${payload[k]}`).join("|");
|
|
1021
|
+
const hash = createHash("sha256").update(canonical).digest("hex");
|
|
1022
|
+
return hash.slice(0, 12);
|
|
1023
|
+
}
|
|
1024
|
+
function generateFindingId(finding) {
|
|
1025
|
+
const payload = extractIdentityPayload(finding);
|
|
1026
|
+
const hash = hashPayload(payload);
|
|
1027
|
+
return `${finding.invariantId}:${hash}`;
|
|
1028
|
+
}
|
|
1029
|
+
function attachFindingId(finding) {
|
|
1030
|
+
const findingId2 = generateFindingId(finding);
|
|
1031
|
+
return Object.assign(finding, { findingId: findingId2 });
|
|
1032
|
+
}
|
|
1033
|
+
function attachFindingIds(findings) {
|
|
1034
|
+
return findings.map(attachFindingId);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// src/baseline/schema.ts
|
|
1038
|
+
var BASELINE_SCHEMA_VERSION = "1.0.0";
|
|
1039
|
+
var WAIVER_SCHEMA_VERSION = "1.1.0";
|
|
1040
|
+
var WAIVER_REASON_KEYS = [
|
|
1041
|
+
"false_positive",
|
|
1042
|
+
"acceptable_risk",
|
|
1043
|
+
"will_fix_later",
|
|
1044
|
+
"not_applicable",
|
|
1045
|
+
"other"
|
|
1046
|
+
];
|
|
1047
|
+
function isValidWaiverReasonKey(value) {
|
|
1048
|
+
return WAIVER_REASON_KEYS.includes(value);
|
|
1049
|
+
}
|
|
1050
|
+
var CLI_PACKAGE_NAME = "@securitychecks/cli";
|
|
1051
|
+
function getGeneratedBy(version) {
|
|
1052
|
+
return `${CLI_PACKAGE_NAME}@${version}`;
|
|
1053
|
+
}
|
|
1054
|
+
function createEmptyBaseline(version = "0.0.0") {
|
|
1055
|
+
return {
|
|
1056
|
+
schemaVersion: BASELINE_SCHEMA_VERSION,
|
|
1057
|
+
toolVersion: version,
|
|
1058
|
+
generatedBy: getGeneratedBy(version),
|
|
1059
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1060
|
+
entries: {}
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
function createEmptyWaiverFile(version = "0.0.0") {
|
|
1064
|
+
return {
|
|
1065
|
+
schemaVersion: WAIVER_SCHEMA_VERSION,
|
|
1066
|
+
toolVersion: version,
|
|
1067
|
+
generatedBy: getGeneratedBy(version),
|
|
1068
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1069
|
+
entries: {}
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
var CLI_VERSION = "0.1.1-rc.1";
|
|
1073
|
+
var SCHECK_DIR = ".scheck";
|
|
1074
|
+
var BASELINE_FILE = "baseline.json";
|
|
1075
|
+
var WAIVER_FILE = "waivers.json";
|
|
1076
|
+
function getBaselinePath(rootPath) {
|
|
1077
|
+
return join(rootPath, SCHECK_DIR, BASELINE_FILE);
|
|
1078
|
+
}
|
|
1079
|
+
function getWaiverPath(rootPath) {
|
|
1080
|
+
return join(rootPath, SCHECK_DIR, WAIVER_FILE);
|
|
1081
|
+
}
|
|
1082
|
+
async function loadBaseline(rootPath) {
|
|
1083
|
+
const path = getBaselinePath(rootPath);
|
|
1084
|
+
if (!existsSync(path)) {
|
|
1085
|
+
return createEmptyBaseline(CLI_VERSION);
|
|
1086
|
+
}
|
|
1087
|
+
try {
|
|
1088
|
+
const content = await readFile(path, "utf-8");
|
|
1089
|
+
const data = JSON.parse(content);
|
|
1090
|
+
if (!data.schemaVersion) {
|
|
1091
|
+
data.schemaVersion = BASELINE_SCHEMA_VERSION;
|
|
1092
|
+
}
|
|
1093
|
+
if (!data.toolVersion) {
|
|
1094
|
+
data.toolVersion = CLI_VERSION;
|
|
1095
|
+
}
|
|
1096
|
+
if (!data.generatedBy) {
|
|
1097
|
+
data.generatedBy = getGeneratedBy(CLI_VERSION);
|
|
1098
|
+
}
|
|
1099
|
+
return data;
|
|
1100
|
+
} catch {
|
|
1101
|
+
console.warn(`Warning: Could not parse baseline file at ${path}, using empty baseline`);
|
|
1102
|
+
return createEmptyBaseline(CLI_VERSION);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
async function saveBaseline(rootPath, baseline, collectorSchemaVersion) {
|
|
1106
|
+
const path = getBaselinePath(rootPath);
|
|
1107
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1108
|
+
baseline.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1109
|
+
baseline.toolVersion = CLI_VERSION;
|
|
1110
|
+
baseline.generatedBy = getGeneratedBy(CLI_VERSION);
|
|
1111
|
+
if (collectorSchemaVersion) {
|
|
1112
|
+
baseline.collectorSchemaVersion = collectorSchemaVersion;
|
|
1113
|
+
}
|
|
1114
|
+
const orderedBaseline = {
|
|
1115
|
+
schemaVersion: baseline.schemaVersion,
|
|
1116
|
+
toolVersion: baseline.toolVersion,
|
|
1117
|
+
...baseline.collectorSchemaVersion ? { collectorSchemaVersion: baseline.collectorSchemaVersion } : {},
|
|
1118
|
+
generatedBy: baseline.generatedBy,
|
|
1119
|
+
updatedAt: baseline.updatedAt,
|
|
1120
|
+
entries: sortEntriesByFindingId(baseline.entries)
|
|
1121
|
+
};
|
|
1122
|
+
await writeFile(path, JSON.stringify(orderedBaseline, null, 2) + "\n", "utf-8");
|
|
1123
|
+
}
|
|
1124
|
+
function sortEntriesByFindingId(entries) {
|
|
1125
|
+
const sorted = {};
|
|
1126
|
+
const keys = Object.keys(entries).sort();
|
|
1127
|
+
for (const key of keys) {
|
|
1128
|
+
sorted[key] = entries[key];
|
|
1129
|
+
}
|
|
1130
|
+
return sorted;
|
|
1131
|
+
}
|
|
1132
|
+
function addToBaseline(baseline, findings, notes) {
|
|
1133
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1134
|
+
let added = 0;
|
|
1135
|
+
for (const finding of findings) {
|
|
1136
|
+
const findingId2 = generateFindingId(finding);
|
|
1137
|
+
if (!baseline.entries[findingId2]) {
|
|
1138
|
+
baseline.entries[findingId2] = {
|
|
1139
|
+
findingId: findingId2,
|
|
1140
|
+
invariantId: finding.invariantId,
|
|
1141
|
+
file: finding.evidence[0]?.file ?? "",
|
|
1142
|
+
symbol: finding.evidence[0]?.symbol,
|
|
1143
|
+
createdAt: now,
|
|
1144
|
+
lastSeenAt: now,
|
|
1145
|
+
notes
|
|
1146
|
+
};
|
|
1147
|
+
added++;
|
|
1148
|
+
} else {
|
|
1149
|
+
baseline.entries[findingId2].lastSeenAt = now;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return added;
|
|
1153
|
+
}
|
|
1154
|
+
function isInBaseline(baseline, finding) {
|
|
1155
|
+
const findingId2 = generateFindingId(finding);
|
|
1156
|
+
return findingId2 in baseline.entries;
|
|
1157
|
+
}
|
|
1158
|
+
function pruneBaseline(baseline, staleDays = 90) {
|
|
1159
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
1160
|
+
cutoff.setDate(cutoff.getDate() - staleDays);
|
|
1161
|
+
const cutoffIso = cutoff.toISOString();
|
|
1162
|
+
let removed = 0;
|
|
1163
|
+
for (const [id, entry] of Object.entries(baseline.entries)) {
|
|
1164
|
+
if (entry.lastSeenAt < cutoffIso) {
|
|
1165
|
+
delete baseline.entries[id];
|
|
1166
|
+
removed++;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return removed;
|
|
1170
|
+
}
|
|
1171
|
+
async function loadWaivers(rootPath) {
|
|
1172
|
+
const path = getWaiverPath(rootPath);
|
|
1173
|
+
if (!existsSync(path)) {
|
|
1174
|
+
return createEmptyWaiverFile(CLI_VERSION);
|
|
1175
|
+
}
|
|
1176
|
+
try {
|
|
1177
|
+
const content = await readFile(path, "utf-8");
|
|
1178
|
+
const data = JSON.parse(content);
|
|
1179
|
+
if (!data.schemaVersion) {
|
|
1180
|
+
data.schemaVersion = WAIVER_SCHEMA_VERSION;
|
|
1181
|
+
}
|
|
1182
|
+
if (!data.toolVersion) {
|
|
1183
|
+
data.toolVersion = CLI_VERSION;
|
|
1184
|
+
}
|
|
1185
|
+
if (!data.generatedBy) {
|
|
1186
|
+
data.generatedBy = getGeneratedBy(CLI_VERSION);
|
|
1187
|
+
}
|
|
1188
|
+
return data;
|
|
1189
|
+
} catch {
|
|
1190
|
+
console.warn(`Warning: Could not parse waiver file at ${path}, using empty waivers`);
|
|
1191
|
+
return createEmptyWaiverFile(CLI_VERSION);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
async function saveWaivers(rootPath, waivers) {
|
|
1195
|
+
const path = getWaiverPath(rootPath);
|
|
1196
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1197
|
+
waivers.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1198
|
+
waivers.toolVersion = CLI_VERSION;
|
|
1199
|
+
waivers.generatedBy = getGeneratedBy(CLI_VERSION);
|
|
1200
|
+
const orderedWaivers = {
|
|
1201
|
+
schemaVersion: waivers.schemaVersion,
|
|
1202
|
+
toolVersion: waivers.toolVersion,
|
|
1203
|
+
generatedBy: waivers.generatedBy,
|
|
1204
|
+
updatedAt: waivers.updatedAt,
|
|
1205
|
+
entries: sortEntriesByFindingId(waivers.entries)
|
|
1206
|
+
};
|
|
1207
|
+
await writeFile(path, JSON.stringify(orderedWaivers, null, 2) + "\n", "utf-8");
|
|
1208
|
+
}
|
|
1209
|
+
function addWaiver(waivers, finding, options) {
|
|
1210
|
+
const now = /* @__PURE__ */ new Date();
|
|
1211
|
+
const expiresAt = new Date(now);
|
|
1212
|
+
expiresAt.setDate(expiresAt.getDate() + options.expiresInDays);
|
|
1213
|
+
const findingId2 = generateFindingId(finding);
|
|
1214
|
+
const entry = {
|
|
1215
|
+
findingId: findingId2,
|
|
1216
|
+
invariantId: finding.invariantId,
|
|
1217
|
+
file: finding.evidence[0]?.file ?? "",
|
|
1218
|
+
symbol: finding.evidence[0]?.symbol,
|
|
1219
|
+
reasonKey: options.reasonKey,
|
|
1220
|
+
reason: options.reason,
|
|
1221
|
+
owner: options.owner,
|
|
1222
|
+
expiresAt: expiresAt.toISOString(),
|
|
1223
|
+
createdAt: now.toISOString()
|
|
1224
|
+
};
|
|
1225
|
+
waivers.entries[findingId2] = entry;
|
|
1226
|
+
return entry;
|
|
1227
|
+
}
|
|
1228
|
+
function getValidWaiver(waivers, finding) {
|
|
1229
|
+
const findingId2 = generateFindingId(finding);
|
|
1230
|
+
const waiver = waivers.entries[findingId2];
|
|
1231
|
+
if (!waiver) {
|
|
1232
|
+
return void 0;
|
|
1233
|
+
}
|
|
1234
|
+
const now = /* @__PURE__ */ new Date();
|
|
1235
|
+
const expiresAt = new Date(waiver.expiresAt);
|
|
1236
|
+
if (expiresAt < now) {
|
|
1237
|
+
return void 0;
|
|
1238
|
+
}
|
|
1239
|
+
return waiver;
|
|
1240
|
+
}
|
|
1241
|
+
function pruneExpiredWaivers(waivers) {
|
|
1242
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1243
|
+
let removed = 0;
|
|
1244
|
+
for (const [id, entry] of Object.entries(waivers.entries)) {
|
|
1245
|
+
if (entry.expiresAt < now) {
|
|
1246
|
+
delete waivers.entries[id];
|
|
1247
|
+
removed++;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return removed;
|
|
1251
|
+
}
|
|
1252
|
+
function getExpiringWaivers(waivers, withinDays = 7) {
|
|
1253
|
+
const now = /* @__PURE__ */ new Date();
|
|
1254
|
+
const threshold = new Date(now);
|
|
1255
|
+
threshold.setDate(threshold.getDate() + withinDays);
|
|
1256
|
+
const thresholdIso = threshold.toISOString();
|
|
1257
|
+
return Object.values(waivers.entries).filter(
|
|
1258
|
+
(entry) => entry.expiresAt > now.toISOString() && entry.expiresAt <= thresholdIso
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// src/baseline/matcher.ts
|
|
1263
|
+
function categorizeFindings(findings, baseline, waivers, failSeverities = ["P0", "P1"]) {
|
|
1264
|
+
const categorized = [];
|
|
1265
|
+
const newFindings = [];
|
|
1266
|
+
const baselinedFindings = [];
|
|
1267
|
+
const waivedFindings = [];
|
|
1268
|
+
for (const finding of findings) {
|
|
1269
|
+
const findingId2 = generateFindingId(finding);
|
|
1270
|
+
const isBaselined = isInBaseline(baseline, finding);
|
|
1271
|
+
const waiver = getValidWaiver(waivers, finding);
|
|
1272
|
+
const isFailSeverity = failSeverities.includes(finding.severity);
|
|
1273
|
+
const shouldFail = isFailSeverity && !isBaselined && !waiver;
|
|
1274
|
+
const categorizedFinding = {
|
|
1275
|
+
...finding,
|
|
1276
|
+
findingId: findingId2,
|
|
1277
|
+
isBaselined,
|
|
1278
|
+
waiver,
|
|
1279
|
+
shouldFail
|
|
1280
|
+
};
|
|
1281
|
+
categorized.push(categorizedFinding);
|
|
1282
|
+
if (waiver) {
|
|
1283
|
+
waivedFindings.push(categorizedFinding);
|
|
1284
|
+
} else if (isBaselined) {
|
|
1285
|
+
baselinedFindings.push(categorizedFinding);
|
|
1286
|
+
} else {
|
|
1287
|
+
newFindings.push(categorizedFinding);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return {
|
|
1291
|
+
all: categorized,
|
|
1292
|
+
new: newFindings,
|
|
1293
|
+
baselined: baselinedFindings,
|
|
1294
|
+
waived: waivedFindings,
|
|
1295
|
+
counts: {
|
|
1296
|
+
total: categorized.length,
|
|
1297
|
+
new: newFindings.length,
|
|
1298
|
+
baselined: baselinedFindings.length,
|
|
1299
|
+
waived: waivedFindings.length,
|
|
1300
|
+
willFail: categorized.filter((f) => f.shouldFail).length
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
function getCIExitCode(result) {
|
|
1305
|
+
return result.counts.willFail > 0 ? 1 : 0;
|
|
1306
|
+
}
|
|
1307
|
+
function getCISummary(result) {
|
|
1308
|
+
const { counts } = result;
|
|
1309
|
+
if (counts.total === 0) {
|
|
1310
|
+
return "No findings detected.";
|
|
1311
|
+
}
|
|
1312
|
+
const parts = [];
|
|
1313
|
+
if (counts.willFail > 0) {
|
|
1314
|
+
parts.push(`${counts.willFail} new finding(s) require attention`);
|
|
1315
|
+
}
|
|
1316
|
+
if (counts.baselined > 0) {
|
|
1317
|
+
parts.push(`${counts.baselined} baselined`);
|
|
1318
|
+
}
|
|
1319
|
+
if (counts.waived > 0) {
|
|
1320
|
+
parts.push(`${counts.waived} waived`);
|
|
1321
|
+
}
|
|
1322
|
+
if (counts.willFail === 0) {
|
|
1323
|
+
parts.unshift("All findings are baselined or waived");
|
|
1324
|
+
}
|
|
1325
|
+
return parts.join(", ") + ".";
|
|
1326
|
+
}
|
|
1327
|
+
function resolveCollisions(findings) {
|
|
1328
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1329
|
+
const result = [];
|
|
1330
|
+
for (const finding of findings) {
|
|
1331
|
+
let findingId2 = generateFindingId(finding);
|
|
1332
|
+
const count = seen.get(findingId2) ?? 0;
|
|
1333
|
+
if (count > 0) {
|
|
1334
|
+
const suffix = String.fromCharCode(96 + count);
|
|
1335
|
+
findingId2 = `${findingId2}:${suffix}`;
|
|
1336
|
+
}
|
|
1337
|
+
seen.set(generateFindingId(finding), count + 1);
|
|
1338
|
+
result.push({
|
|
1339
|
+
...finding,
|
|
1340
|
+
findingId: findingId2
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
return result;
|
|
1344
|
+
}
|
|
1345
|
+
function hasCollisions(findings) {
|
|
1346
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1347
|
+
for (const finding of findings) {
|
|
1348
|
+
const id = generateFindingId(finding);
|
|
1349
|
+
if (ids.has(id)) {
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
ids.add(id);
|
|
1353
|
+
}
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// src/lib/correlation.ts
|
|
1358
|
+
var COMPOUNDING_RULES = [
|
|
1359
|
+
// Webhook + Transaction = Replay causes inconsistent state
|
|
1360
|
+
{
|
|
1361
|
+
invariants: ["WEBHOOK.IDEMPOTENT", "TRANSACTION.POST_COMMIT.SIDE_EFFECTS"],
|
|
1362
|
+
effect: {
|
|
1363
|
+
description: "Webhook replay can cause duplicate side effects AND inconsistent database state",
|
|
1364
|
+
riskMultiplier: 2,
|
|
1365
|
+
signals: ["webhook_replay", "transaction_side_effect", "data_inconsistency"]
|
|
1366
|
+
},
|
|
1367
|
+
attackPathTemplate: {
|
|
1368
|
+
title: "Webhook Replay Attack with Data Inconsistency",
|
|
1369
|
+
exploitability: "easy",
|
|
1370
|
+
impact: "high",
|
|
1371
|
+
timeWindow: "Immediate - no time limit on replay"
|
|
1372
|
+
}
|
|
1373
|
+
},
|
|
1374
|
+
// No auth + No service auth = Complete bypass
|
|
1375
|
+
{
|
|
1376
|
+
invariants: ["AUTHZ.SERVICE_LAYER.ENFORCED", "AUTHZ.MEMBERSHIP.REVOCATION.IMMEDIATE"],
|
|
1377
|
+
effect: {
|
|
1378
|
+
description: "Missing service-layer auth combined with delayed revocation allows extended unauthorized access",
|
|
1379
|
+
riskMultiplier: 2.5,
|
|
1380
|
+
signals: ["auth_bypass", "delayed_revocation", "privilege_persistence"]
|
|
1381
|
+
},
|
|
1382
|
+
attackPathTemplate: {
|
|
1383
|
+
title: "Extended Privilege Persistence",
|
|
1384
|
+
exploitability: "medium",
|
|
1385
|
+
impact: "critical",
|
|
1386
|
+
timeWindow: "Until cache expires or session timeout"
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
// Cache + Membership revocation = Stale permissions
|
|
1390
|
+
{
|
|
1391
|
+
invariants: ["CACHE.INVALIDATION.ON_AUTH_CHANGE", "AUTHZ.MEMBERSHIP.REVOCATION.IMMEDIATE"],
|
|
1392
|
+
effect: {
|
|
1393
|
+
description: "Membership change without cache invalidation allows continued access via stale cache",
|
|
1394
|
+
riskMultiplier: 2,
|
|
1395
|
+
signals: ["stale_cache", "permission_leak", "revocation_bypass"]
|
|
1396
|
+
},
|
|
1397
|
+
attackPathTemplate: {
|
|
1398
|
+
title: "Stale Permission Cache Exploit",
|
|
1399
|
+
exploitability: "medium",
|
|
1400
|
+
impact: "high",
|
|
1401
|
+
timeWindow: "Cache TTL (often 5-15 minutes)"
|
|
1402
|
+
}
|
|
1403
|
+
},
|
|
1404
|
+
// Transaction + Cache = Inconsistent read after rollback
|
|
1405
|
+
{
|
|
1406
|
+
invariants: ["TRANSACTION.POST_COMMIT.SIDE_EFFECTS", "CACHE.INVALIDATION.ON_AUTH_CHANGE"],
|
|
1407
|
+
effect: {
|
|
1408
|
+
description: "Side effect in transaction + missing cache invalidation can leave cache inconsistent after rollback",
|
|
1409
|
+
riskMultiplier: 1.5,
|
|
1410
|
+
signals: ["rollback_inconsistency", "cache_stale", "side_effect_mismatch"]
|
|
1411
|
+
}
|
|
1412
|
+
},
|
|
1413
|
+
// Billing + Auth = Free tier bypass
|
|
1414
|
+
{
|
|
1415
|
+
invariants: ["BILLING.SERVER_ENFORCED", "AUTHZ.SERVICE_LAYER.ENFORCED"],
|
|
1416
|
+
effect: {
|
|
1417
|
+
description: "Missing billing enforcement + auth gap allows access to paid features without payment",
|
|
1418
|
+
riskMultiplier: 2,
|
|
1419
|
+
signals: ["billing_bypass", "feature_theft", "revenue_loss"]
|
|
1420
|
+
},
|
|
1421
|
+
attackPathTemplate: {
|
|
1422
|
+
title: "Billing Bypass via Auth Gap",
|
|
1423
|
+
exploitability: "medium",
|
|
1424
|
+
impact: "high"
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
// Jobs + Transaction = Retry causes duplicate side effects
|
|
1428
|
+
{
|
|
1429
|
+
invariants: ["JOBS.RETRY_SAFE", "TRANSACTION.POST_COMMIT.SIDE_EFFECTS"],
|
|
1430
|
+
effect: {
|
|
1431
|
+
description: "Non-idempotent job with side effects in transaction can cause duplicates on retry",
|
|
1432
|
+
riskMultiplier: 1.8,
|
|
1433
|
+
signals: ["job_retry", "duplicate_side_effect", "data_duplication"]
|
|
1434
|
+
}
|
|
1435
|
+
},
|
|
1436
|
+
// API Key + Cache = Revoked key still works
|
|
1437
|
+
{
|
|
1438
|
+
invariants: ["AUTHZ.KEYS.REVOCATION.IMMEDIATE", "CACHE.INVALIDATION.ON_AUTH_CHANGE"],
|
|
1439
|
+
effect: {
|
|
1440
|
+
description: "API key revocation without cache invalidation allows continued API access",
|
|
1441
|
+
riskMultiplier: 2,
|
|
1442
|
+
signals: ["key_still_valid", "cache_bypass", "api_access_leak"]
|
|
1443
|
+
},
|
|
1444
|
+
attackPathTemplate: {
|
|
1445
|
+
title: "API Key Revocation Bypass",
|
|
1446
|
+
exploitability: "easy",
|
|
1447
|
+
impact: "high",
|
|
1448
|
+
timeWindow: "Until cache expires"
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
];
|
|
1452
|
+
function correlateFindings(results, artifact) {
|
|
1453
|
+
const allFindings = results.flatMap((r) => r.findings);
|
|
1454
|
+
if (allFindings.length === 0) {
|
|
1455
|
+
return {
|
|
1456
|
+
correlations: [],
|
|
1457
|
+
stats: {
|
|
1458
|
+
totalFindings: 0,
|
|
1459
|
+
correlatedFindings: 0,
|
|
1460
|
+
correlationGroups: 0,
|
|
1461
|
+
severityEscalations: 0
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
const groups = groupFindingsByLocation(allFindings);
|
|
1466
|
+
const correlations = [];
|
|
1467
|
+
let severityEscalations = 0;
|
|
1468
|
+
for (const group of groups.values()) {
|
|
1469
|
+
if (group.length < 2) continue;
|
|
1470
|
+
const correlation = findCorrelation(group);
|
|
1471
|
+
if (correlation) {
|
|
1472
|
+
correlations.push(correlation);
|
|
1473
|
+
if (severityToNumber(correlation.adjustedSeverity) > severityToNumber(correlation.primary.severity)) {
|
|
1474
|
+
severityEscalations++;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const correlatedFindingIds = /* @__PURE__ */ new Set();
|
|
1479
|
+
for (const c of correlations) {
|
|
1480
|
+
correlatedFindingIds.add(findingId(c.primary));
|
|
1481
|
+
for (const r of c.related) {
|
|
1482
|
+
correlatedFindingIds.add(findingId(r));
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
return {
|
|
1486
|
+
correlations,
|
|
1487
|
+
stats: {
|
|
1488
|
+
totalFindings: allFindings.length,
|
|
1489
|
+
correlatedFindings: correlatedFindingIds.size,
|
|
1490
|
+
correlationGroups: correlations.length,
|
|
1491
|
+
severityEscalations
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
function groupFindingsByLocation(findings) {
|
|
1496
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1497
|
+
for (const finding of findings) {
|
|
1498
|
+
const evidence = finding.evidence[0];
|
|
1499
|
+
if (!evidence) continue;
|
|
1500
|
+
const key = `${evidence.file}:${evidence.symbol ?? "unknown"}`;
|
|
1501
|
+
if (!groups.has(key)) {
|
|
1502
|
+
groups.set(key, []);
|
|
1503
|
+
}
|
|
1504
|
+
groups.get(key).push(finding);
|
|
1505
|
+
}
|
|
1506
|
+
return groups;
|
|
1507
|
+
}
|
|
1508
|
+
function findCorrelation(findings, _artifact) {
|
|
1509
|
+
const invariantIds = new Set(findings.map((f) => f.invariantId));
|
|
1510
|
+
let bestMatch = null;
|
|
1511
|
+
let matchCount = 0;
|
|
1512
|
+
for (const rule of COMPOUNDING_RULES) {
|
|
1513
|
+
const matches = rule.invariants.filter((inv) => invariantIds.has(inv));
|
|
1514
|
+
if (matches.length >= 2 && matches.length > matchCount) {
|
|
1515
|
+
bestMatch = rule;
|
|
1516
|
+
matchCount = matches.length;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (!bestMatch) {
|
|
1520
|
+
if (findings.length >= 2) {
|
|
1521
|
+
return createGenericCorrelation(findings);
|
|
1522
|
+
}
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
const matchingFindings = findings.filter(
|
|
1526
|
+
(f) => bestMatch.invariants.includes(f.invariantId)
|
|
1527
|
+
);
|
|
1528
|
+
matchingFindings.sort(
|
|
1529
|
+
(a, b) => severityToNumber(b.severity) - severityToNumber(a.severity)
|
|
1530
|
+
);
|
|
1531
|
+
const primary = matchingFindings[0];
|
|
1532
|
+
const related = matchingFindings.slice(1);
|
|
1533
|
+
const evidence = primary.evidence[0];
|
|
1534
|
+
const sharedContext = {
|
|
1535
|
+
file: evidence?.file,
|
|
1536
|
+
functionName: evidence?.symbol,
|
|
1537
|
+
findingCount: matchingFindings.length
|
|
1538
|
+
};
|
|
1539
|
+
const adjustedSeverity = calculateAdjustedSeverity(
|
|
1540
|
+
primary.severity,
|
|
1541
|
+
bestMatch.effect.riskMultiplier
|
|
1542
|
+
);
|
|
1543
|
+
let attackPath;
|
|
1544
|
+
if (bestMatch.attackPathTemplate) {
|
|
1545
|
+
attackPath = buildAttackPath(
|
|
1546
|
+
bestMatch.attackPathTemplate,
|
|
1547
|
+
matchingFindings
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
return {
|
|
1551
|
+
primary,
|
|
1552
|
+
related,
|
|
1553
|
+
sharedContext,
|
|
1554
|
+
compoundingEffect: bestMatch.effect,
|
|
1555
|
+
adjustedSeverity,
|
|
1556
|
+
attackPath
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
function createGenericCorrelation(findings) {
|
|
1560
|
+
findings.sort(
|
|
1561
|
+
(a, b) => severityToNumber(b.severity) - severityToNumber(a.severity)
|
|
1562
|
+
);
|
|
1563
|
+
const primary = findings[0];
|
|
1564
|
+
const related = findings.slice(1);
|
|
1565
|
+
const evidence = primary.evidence[0];
|
|
1566
|
+
return {
|
|
1567
|
+
primary,
|
|
1568
|
+
related,
|
|
1569
|
+
sharedContext: {
|
|
1570
|
+
file: evidence?.file,
|
|
1571
|
+
functionName: evidence?.symbol,
|
|
1572
|
+
findingCount: findings.length
|
|
1573
|
+
},
|
|
1574
|
+
compoundingEffect: {
|
|
1575
|
+
description: `Multiple security issues in the same location (${findings.length} findings)`,
|
|
1576
|
+
riskMultiplier: 1 + (findings.length - 1) * 0.2,
|
|
1577
|
+
signals: findings.map((f) => f.invariantId)
|
|
1578
|
+
},
|
|
1579
|
+
adjustedSeverity: primary.severity
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
function buildAttackPath(template, findings) {
|
|
1583
|
+
const steps = [];
|
|
1584
|
+
for (let i = 0; i < findings.length; i++) {
|
|
1585
|
+
const finding = findings[i];
|
|
1586
|
+
const evidence = finding.evidence[0];
|
|
1587
|
+
steps.push({
|
|
1588
|
+
step: i + 1,
|
|
1589
|
+
description: getAttackStepDescription(finding),
|
|
1590
|
+
invariantId: finding.invariantId,
|
|
1591
|
+
location: evidence ? { file: evidence.file, line: evidence.line } : void 0
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
return {
|
|
1595
|
+
...template,
|
|
1596
|
+
steps
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
function getAttackStepDescription(finding) {
|
|
1600
|
+
const invariant = finding.invariantId;
|
|
1601
|
+
switch (invariant) {
|
|
1602
|
+
case "WEBHOOK.IDEMPOTENT":
|
|
1603
|
+
return "Attacker replays webhook request (no idempotency protection)";
|
|
1604
|
+
case "TRANSACTION.POST_COMMIT.SIDE_EFFECTS":
|
|
1605
|
+
return "Side effect fires inside transaction (may be duplicated or inconsistent)";
|
|
1606
|
+
case "AUTHZ.SERVICE_LAYER.ENFORCED":
|
|
1607
|
+
return "Service function called without authorization check";
|
|
1608
|
+
case "AUTHZ.MEMBERSHIP.REVOCATION.IMMEDIATE":
|
|
1609
|
+
return "Membership/role change does not immediately revoke access";
|
|
1610
|
+
case "AUTHZ.KEYS.REVOCATION.IMMEDIATE":
|
|
1611
|
+
return "API key revocation does not immediately invalidate the key";
|
|
1612
|
+
case "CACHE.INVALIDATION.ON_AUTH_CHANGE":
|
|
1613
|
+
return "Auth change does not invalidate cached permissions";
|
|
1614
|
+
case "BILLING.SERVER_ENFORCED":
|
|
1615
|
+
return "Billing/entitlement check bypassed or missing";
|
|
1616
|
+
case "JOBS.RETRY_SAFE":
|
|
1617
|
+
return "Background job is not idempotent (retry causes duplicates)";
|
|
1618
|
+
default:
|
|
1619
|
+
return finding.message;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function severityToNumber(severity) {
|
|
1623
|
+
switch (severity) {
|
|
1624
|
+
case "P0":
|
|
1625
|
+
return 3;
|
|
1626
|
+
case "P1":
|
|
1627
|
+
return 2;
|
|
1628
|
+
case "P2":
|
|
1629
|
+
return 1;
|
|
1630
|
+
default:
|
|
1631
|
+
return 0;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
function numberToSeverity(num) {
|
|
1635
|
+
if (num >= 3) return "P0";
|
|
1636
|
+
if (num >= 2) return "P1";
|
|
1637
|
+
return "P2";
|
|
1638
|
+
}
|
|
1639
|
+
function calculateAdjustedSeverity(baseSeverity, multiplier) {
|
|
1640
|
+
const base = severityToNumber(baseSeverity);
|
|
1641
|
+
const adjusted = Math.min(3, Math.ceil(base * multiplier));
|
|
1642
|
+
return numberToSeverity(adjusted);
|
|
1643
|
+
}
|
|
1644
|
+
function findingId(finding) {
|
|
1645
|
+
const evidence = finding.evidence[0];
|
|
1646
|
+
return `${finding.invariantId}:${evidence?.file ?? "unknown"}:${evidence?.line ?? 0}`;
|
|
1647
|
+
}
|
|
1648
|
+
function formatCorrelatedFinding(correlation) {
|
|
1649
|
+
const lines = [];
|
|
1650
|
+
const location = correlation.sharedContext.file ? `${correlation.sharedContext.file}:${correlation.sharedContext.functionName ?? "unknown"}` : "Unknown location";
|
|
1651
|
+
lines.push(`
|
|
1652
|
+
\u250C\u2500 CORRELATED FINDINGS \u2500 ${location}`);
|
|
1653
|
+
lines.push(`\u2502`);
|
|
1654
|
+
lines.push(`\u2502 [${correlation.adjustedSeverity}] ${correlation.primary.message}`);
|
|
1655
|
+
lines.push(`\u2502 \u2514\u2500 ${correlation.primary.invariantId}`);
|
|
1656
|
+
for (const related of correlation.related) {
|
|
1657
|
+
lines.push(`\u2502 [${related.severity}] ${related.message}`);
|
|
1658
|
+
lines.push(`\u2502 \u2514\u2500 ${related.invariantId}`);
|
|
1659
|
+
}
|
|
1660
|
+
lines.push(`\u2502`);
|
|
1661
|
+
lines.push(`\u2502 \u26A0 Compounding Effect:`);
|
|
1662
|
+
lines.push(`\u2502 ${correlation.compoundingEffect.description}`);
|
|
1663
|
+
lines.push(`\u2502 Risk multiplier: ${correlation.compoundingEffect.riskMultiplier}x`);
|
|
1664
|
+
if (correlation.attackPath) {
|
|
1665
|
+
lines.push(`\u2502`);
|
|
1666
|
+
lines.push(`\u2502 \u{1F3AF} Attack Path: ${correlation.attackPath.title}`);
|
|
1667
|
+
lines.push(`\u2502 Exploitability: ${correlation.attackPath.exploitability}`);
|
|
1668
|
+
lines.push(`\u2502 Impact: ${correlation.attackPath.impact}`);
|
|
1669
|
+
if (correlation.attackPath.timeWindow) {
|
|
1670
|
+
lines.push(`\u2502 Time window: ${correlation.attackPath.timeWindow}`);
|
|
1671
|
+
}
|
|
1672
|
+
lines.push(`\u2502`);
|
|
1673
|
+
for (const step of correlation.attackPath.steps) {
|
|
1674
|
+
lines.push(`\u2502 ${step.step}. ${step.description}`);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
lines.push(`\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
1678
|
+
return lines.join("\n");
|
|
1679
|
+
}
|
|
1680
|
+
function formatCorrelationStats(result) {
|
|
1681
|
+
const { stats } = result;
|
|
1682
|
+
return `
|
|
1683
|
+
Correlation Analysis:
|
|
1684
|
+
Total findings: ${stats.totalFindings}
|
|
1685
|
+
Correlated: ${stats.correlatedFindings} (${Math.round(stats.correlatedFindings / stats.totalFindings * 100)}%)
|
|
1686
|
+
Correlation groups: ${stats.correlationGroups}
|
|
1687
|
+
Severity escalations: ${stats.severityEscalations}
|
|
1688
|
+
`.trim();
|
|
1689
|
+
}
|
|
1690
|
+
var DEFAULT_ENDPOINT = "https://api.securitychecks.ai/v1/correlations";
|
|
1691
|
+
function toObservation(correlation, framework) {
|
|
1692
|
+
const allFindings = [correlation.primary, ...correlation.related];
|
|
1693
|
+
const invariants = [...new Set(allFindings.map((f) => f.invariantId))];
|
|
1694
|
+
return {
|
|
1695
|
+
invariants,
|
|
1696
|
+
context: {
|
|
1697
|
+
framework,
|
|
1698
|
+
file: correlation.sharedContext.file,
|
|
1699
|
+
functionName: correlation.sharedContext.functionName,
|
|
1700
|
+
route: correlation.sharedContext.route
|
|
1701
|
+
},
|
|
1702
|
+
stats: {
|
|
1703
|
+
findingCount: correlation.sharedContext.findingCount,
|
|
1704
|
+
severityBefore: correlation.primary.severity,
|
|
1705
|
+
severityAfter: correlation.adjustedSeverity,
|
|
1706
|
+
wasEscalated: correlation.adjustedSeverity !== correlation.primary.severity,
|
|
1707
|
+
riskMultiplier: correlation.compoundingEffect.riskMultiplier
|
|
1708
|
+
},
|
|
1709
|
+
attackPath: correlation.attackPath ? {
|
|
1710
|
+
title: correlation.attackPath.title,
|
|
1711
|
+
exploitability: correlation.attackPath.exploitability,
|
|
1712
|
+
impact: correlation.attackPath.impact,
|
|
1713
|
+
timeWindow: correlation.attackPath.timeWindow,
|
|
1714
|
+
steps: correlation.attackPath.steps
|
|
1715
|
+
} : void 0,
|
|
1716
|
+
compoundingEffect: {
|
|
1717
|
+
description: correlation.compoundingEffect.description,
|
|
1718
|
+
signals: correlation.compoundingEffect.signals
|
|
1719
|
+
},
|
|
1720
|
+
meta: {
|
|
1721
|
+
clientVersion: "0.1.1-rc.1",
|
|
1722
|
+
requestId: randomUUID(),
|
|
1723
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
async function reportCorrelations(result, config, framework) {
|
|
1728
|
+
if (!config.enabled || result.correlations.length === 0) {
|
|
1729
|
+
return { success: true, stored: 0 };
|
|
1730
|
+
}
|
|
1731
|
+
const endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
1732
|
+
const timeout = config.timeout ?? 5e3;
|
|
1733
|
+
try {
|
|
1734
|
+
const observations = result.correlations.map((c) => toObservation(c, framework));
|
|
1735
|
+
const payload = {
|
|
1736
|
+
correlations: observations,
|
|
1737
|
+
summary: result.stats,
|
|
1738
|
+
meta: {
|
|
1739
|
+
clientVersion: "0.1.1-rc.1",
|
|
1740
|
+
framework
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
const controller = new AbortController();
|
|
1744
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1745
|
+
try {
|
|
1746
|
+
const response = await fetch(endpoint, {
|
|
1747
|
+
method: "POST",
|
|
1748
|
+
headers: {
|
|
1749
|
+
"Content-Type": "application/json",
|
|
1750
|
+
...config.apiKey && { Authorization: `Bearer ${config.apiKey}` },
|
|
1751
|
+
"X-Client-Version": "0.1.1-rc.1"
|
|
1752
|
+
},
|
|
1753
|
+
body: JSON.stringify(payload),
|
|
1754
|
+
signal: controller.signal
|
|
1755
|
+
});
|
|
1756
|
+
clearTimeout(timeoutId);
|
|
1757
|
+
if (!response.ok) {
|
|
1758
|
+
return { success: false };
|
|
1759
|
+
}
|
|
1760
|
+
const data = await response.json();
|
|
1761
|
+
return {
|
|
1762
|
+
success: true,
|
|
1763
|
+
stored: data.stored ?? observations.length,
|
|
1764
|
+
errors: data.errors ?? 0
|
|
1765
|
+
};
|
|
1766
|
+
} finally {
|
|
1767
|
+
clearTimeout(timeoutId);
|
|
1768
|
+
}
|
|
1769
|
+
} catch (_error) {
|
|
1770
|
+
return { success: false };
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
async function reportCorrelationFeedback(requestId, wasAccurate, reason, config) {
|
|
1774
|
+
const endpoint = config?.endpoint ?? DEFAULT_ENDPOINT;
|
|
1775
|
+
try {
|
|
1776
|
+
const response = await fetch(endpoint, {
|
|
1777
|
+
method: "PATCH",
|
|
1778
|
+
headers: {
|
|
1779
|
+
"Content-Type": "application/json",
|
|
1780
|
+
...config?.apiKey && { Authorization: `Bearer ${config.apiKey}` }
|
|
1781
|
+
},
|
|
1782
|
+
body: JSON.stringify({
|
|
1783
|
+
requestId,
|
|
1784
|
+
wasAccurate,
|
|
1785
|
+
feedbackReason: reason
|
|
1786
|
+
})
|
|
1787
|
+
});
|
|
1788
|
+
return response.ok;
|
|
1789
|
+
} catch {
|
|
1790
|
+
return false;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
var DEFAULT_ENDPOINT2 = "https://api.securitychecks.ai/v1/telemetry";
|
|
1794
|
+
function buildTelemetry(result, options) {
|
|
1795
|
+
const byInvariant = {};
|
|
1796
|
+
for (const checkResult of result.results) {
|
|
1797
|
+
byInvariant[checkResult.invariantId] = checkResult.findings.length;
|
|
1798
|
+
}
|
|
1799
|
+
const ciProvider = detectCIProvider();
|
|
1800
|
+
return {
|
|
1801
|
+
scanId: randomUUID(),
|
|
1802
|
+
codebase: {
|
|
1803
|
+
filesScanned: options.filesScanned,
|
|
1804
|
+
servicesCount: result.artifact.services.length
|
|
1805
|
+
},
|
|
1806
|
+
frameworks: options.frameworks,
|
|
1807
|
+
findings: {
|
|
1808
|
+
byInvariant,
|
|
1809
|
+
byPriority: result.summary.byPriority,
|
|
1810
|
+
total: result.summary.byPriority.P0 + result.summary.byPriority.P1 + result.summary.byPriority.P2
|
|
1811
|
+
},
|
|
1812
|
+
correlation: options.correlation ? {
|
|
1813
|
+
groups: options.correlation.stats.correlationGroups,
|
|
1814
|
+
escalations: options.correlation.stats.severityEscalations,
|
|
1815
|
+
correlatedFindings: options.correlation.stats.correlatedFindings
|
|
1816
|
+
} : void 0,
|
|
1817
|
+
calibration: options.calibratedCount !== void 0 ? {
|
|
1818
|
+
calibrated: options.calibratedCount,
|
|
1819
|
+
suppressed: options.suppressedCount ?? 0
|
|
1820
|
+
} : void 0,
|
|
1821
|
+
patterns: options.patternsApplied !== void 0 ? {
|
|
1822
|
+
applied: options.patternsApplied,
|
|
1823
|
+
findings: options.patternFindings ?? 0
|
|
1824
|
+
} : void 0,
|
|
1825
|
+
meta: {
|
|
1826
|
+
duration: result.duration,
|
|
1827
|
+
clientVersion: "0.1.1-rc.1",
|
|
1828
|
+
mode: options.mode ?? (ciProvider ? "ci" : "manual"),
|
|
1829
|
+
ciProvider
|
|
1830
|
+
},
|
|
1831
|
+
baseline: options.categorization ? {
|
|
1832
|
+
size: options.baselineSize ?? 0,
|
|
1833
|
+
waivers: options.waiversCount ?? 0,
|
|
1834
|
+
newFindings: options.categorization.counts.new
|
|
1835
|
+
} : void 0,
|
|
1836
|
+
feedback: options.categorization ? {
|
|
1837
|
+
waivedCount: options.categorization.counts.waived,
|
|
1838
|
+
waiverReasons: buildWaiverReasonCounts(options.categorization),
|
|
1839
|
+
baselinedCount: options.categorization.counts.baselined
|
|
1840
|
+
} : void 0
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
async function reportTelemetry(telemetry, config) {
|
|
1844
|
+
if (!config.enabled) {
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
const endpoint = config.endpoint ?? DEFAULT_ENDPOINT2;
|
|
1848
|
+
const timeout = config.timeout ?? 5e3;
|
|
1849
|
+
try {
|
|
1850
|
+
const controller = new AbortController();
|
|
1851
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1852
|
+
try {
|
|
1853
|
+
const response = await fetch(endpoint, {
|
|
1854
|
+
method: "POST",
|
|
1855
|
+
headers: {
|
|
1856
|
+
"Content-Type": "application/json",
|
|
1857
|
+
...config.apiKey && { Authorization: `Bearer ${config.apiKey}` },
|
|
1858
|
+
"X-Client-Version": telemetry.meta.clientVersion
|
|
1859
|
+
},
|
|
1860
|
+
body: JSON.stringify(telemetry),
|
|
1861
|
+
signal: controller.signal
|
|
1862
|
+
});
|
|
1863
|
+
clearTimeout(timeoutId);
|
|
1864
|
+
return response.ok;
|
|
1865
|
+
} finally {
|
|
1866
|
+
clearTimeout(timeoutId);
|
|
1867
|
+
}
|
|
1868
|
+
} catch {
|
|
1869
|
+
return false;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
function buildWaiverReasonCounts(categorization) {
|
|
1873
|
+
const counts = {};
|
|
1874
|
+
for (const finding of categorization.waived) {
|
|
1875
|
+
const waiver = finding.waiver;
|
|
1876
|
+
if (!waiver) continue;
|
|
1877
|
+
const candidate = waiver.reasonKey ?? waiver.reason;
|
|
1878
|
+
const key = candidate && isValidWaiverReasonKey(candidate) ? candidate : "other";
|
|
1879
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
1880
|
+
}
|
|
1881
|
+
return counts;
|
|
1882
|
+
}
|
|
1883
|
+
function detectCIProvider() {
|
|
1884
|
+
if (process.env["GITHUB_ACTIONS"]) return "github";
|
|
1885
|
+
if (process.env["GITLAB_CI"]) return "gitlab";
|
|
1886
|
+
if (process.env["JENKINS_URL"]) return "jenkins";
|
|
1887
|
+
if (process.env["CIRCLECI"]) return "circleci";
|
|
1888
|
+
if (process.env["TRAVIS"]) return "travis";
|
|
1889
|
+
if (process.env["BITBUCKET_BUILD_NUMBER"]) return "bitbucket";
|
|
1890
|
+
if (process.env["AZURE_PIPELINES"]) return "azure";
|
|
1891
|
+
if (process.env["CI"]) return "unknown";
|
|
1892
|
+
return void 0;
|
|
1893
|
+
}
|
|
1894
|
+
function isTelemetryDisabled() {
|
|
1895
|
+
return process.env["SECURITYCHECKS_TELEMETRY"] === "false" || process.env["DO_NOT_TRACK"] === "1";
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// src/lib/calibration.ts
|
|
1899
|
+
var AGGREGATE_CALIBRATION_ENDPOINT = "https://api.securitychecks.ai/v1/calibration";
|
|
1900
|
+
var AGGREGATE_CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
1901
|
+
var aggregateCache = null;
|
|
1902
|
+
var aggregateCacheTimestamp = 0;
|
|
1903
|
+
async function fetchAggregateCalibration(frameworks, config) {
|
|
1904
|
+
if (!config.enabled) {
|
|
1905
|
+
return { data: null, fromCache: false, error: "Aggregate calibration disabled" };
|
|
1906
|
+
}
|
|
1907
|
+
if (config.cacheEnabled !== false && aggregateCache && Date.now() - aggregateCacheTimestamp < AGGREGATE_CACHE_TTL_MS) {
|
|
1908
|
+
return { data: aggregateCache, fromCache: true };
|
|
1909
|
+
}
|
|
1910
|
+
const endpoint = config.endpoint ?? AGGREGATE_CALIBRATION_ENDPOINT;
|
|
1911
|
+
const timeout = config.timeout ?? 5e3;
|
|
1912
|
+
try {
|
|
1913
|
+
const controller = new AbortController();
|
|
1914
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1915
|
+
try {
|
|
1916
|
+
const url = new URL(endpoint);
|
|
1917
|
+
url.searchParams.set("frameworks", frameworks.join(","));
|
|
1918
|
+
const response = await fetch(url.toString(), {
|
|
1919
|
+
method: "GET",
|
|
1920
|
+
headers: {
|
|
1921
|
+
"Accept": "application/json",
|
|
1922
|
+
...config.apiKey && { Authorization: `Bearer ${config.apiKey}` }
|
|
1923
|
+
},
|
|
1924
|
+
signal: controller.signal
|
|
1925
|
+
});
|
|
1926
|
+
clearTimeout(timeoutId);
|
|
1927
|
+
if (!response.ok) {
|
|
1928
|
+
return {
|
|
1929
|
+
data: null,
|
|
1930
|
+
fromCache: false,
|
|
1931
|
+
error: `HTTP ${response.status}: ${response.statusText}`
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
const data = await response.json();
|
|
1935
|
+
if (config.cacheEnabled !== false) {
|
|
1936
|
+
aggregateCache = data;
|
|
1937
|
+
aggregateCacheTimestamp = Date.now();
|
|
1938
|
+
}
|
|
1939
|
+
return { data, fromCache: false };
|
|
1940
|
+
} finally {
|
|
1941
|
+
clearTimeout(timeoutId);
|
|
1942
|
+
}
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
return {
|
|
1945
|
+
data: null,
|
|
1946
|
+
fromCache: false,
|
|
1947
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
function clearAggregateCache() {
|
|
1952
|
+
aggregateCache = null;
|
|
1953
|
+
aggregateCacheTimestamp = 0;
|
|
1954
|
+
}
|
|
1955
|
+
function getFrameworkBaseline(calibration, framework) {
|
|
1956
|
+
return calibration.frameworks.find((f) => f.framework === framework);
|
|
1957
|
+
}
|
|
1958
|
+
function shouldSkipPattern(calibration, patternId, framework, accuracyThreshold = 0.3) {
|
|
1959
|
+
const pattern = calibration.patterns.find(
|
|
1960
|
+
(p) => p.patternId === patternId && (p.framework === framework || p.framework === null)
|
|
1961
|
+
);
|
|
1962
|
+
if (!pattern || pattern.accuracy === null) {
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1965
|
+
if (pattern.confidence !== "high") {
|
|
1966
|
+
return false;
|
|
1967
|
+
}
|
|
1968
|
+
return pattern.accuracy < accuracyThreshold;
|
|
1969
|
+
}
|
|
1970
|
+
function getSkippedPatterns(calibration, framework, accuracyThreshold = 0.3) {
|
|
1971
|
+
return calibration.patterns.filter((p) => {
|
|
1972
|
+
if (p.accuracy === null || p.confidence !== "high") {
|
|
1973
|
+
return false;
|
|
1974
|
+
}
|
|
1975
|
+
if (framework && p.framework && p.framework !== framework) {
|
|
1976
|
+
return false;
|
|
1977
|
+
}
|
|
1978
|
+
return p.accuracy < accuracyThreshold;
|
|
1979
|
+
}).map((p) => p.patternId);
|
|
1980
|
+
}
|
|
1981
|
+
function getVerifiedCorrelations(calibration) {
|
|
1982
|
+
return calibration.correlations.filter((c) => c.isVerified);
|
|
1983
|
+
}
|
|
1984
|
+
function calculateRelativeSeverity(findingCount, baseline, type = "total") {
|
|
1985
|
+
let avg;
|
|
1986
|
+
switch (type) {
|
|
1987
|
+
case "P0":
|
|
1988
|
+
avg = baseline.avgP0;
|
|
1989
|
+
break;
|
|
1990
|
+
case "P1":
|
|
1991
|
+
avg = baseline.avgP1;
|
|
1992
|
+
break;
|
|
1993
|
+
case "P2":
|
|
1994
|
+
avg = baseline.avgP2;
|
|
1995
|
+
break;
|
|
1996
|
+
default:
|
|
1997
|
+
avg = baseline.avgFindings;
|
|
1998
|
+
}
|
|
1999
|
+
if (avg === 0) {
|
|
2000
|
+
return findingCount > 0 ? "above_average" : "average";
|
|
2001
|
+
}
|
|
2002
|
+
const ratio = findingCount / avg;
|
|
2003
|
+
if (ratio < 0.5) return "below_average";
|
|
2004
|
+
if (ratio < 1.5) return "average";
|
|
2005
|
+
if (ratio < 3) return "above_average";
|
|
2006
|
+
return "critical";
|
|
2007
|
+
}
|
|
2008
|
+
function formatAggregateCalibrationSummary(calibration, frameworks, findings) {
|
|
2009
|
+
const lines = [];
|
|
2010
|
+
for (const fw of frameworks) {
|
|
2011
|
+
const baseline = getFrameworkBaseline(calibration, fw);
|
|
2012
|
+
if (baseline && baseline.confidence !== "low") {
|
|
2013
|
+
const severity = calculateRelativeSeverity(findings.total, baseline);
|
|
2014
|
+
const avgStr = baseline.avgFindings.toFixed(1);
|
|
2015
|
+
if (severity === "below_average") {
|
|
2016
|
+
lines.push(`${fw}: ${findings.total} findings (${avgStr} avg) - Below average`);
|
|
2017
|
+
} else if (severity === "above_average") {
|
|
2018
|
+
lines.push(`${fw}: ${findings.total} findings (${avgStr} avg) - Above average`);
|
|
2019
|
+
} else if (severity === "critical") {
|
|
2020
|
+
lines.push(`${fw}: ${findings.total} findings (${avgStr} avg) - Significantly above average`);
|
|
2021
|
+
} else {
|
|
2022
|
+
lines.push(`${fw}: ${findings.total} findings (${avgStr} avg) - Typical`);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
const skipped = getSkippedPatterns(calibration, frameworks[0]);
|
|
2027
|
+
if (skipped.length > 0) {
|
|
2028
|
+
lines.push(`Skipped ${skipped.length} low-accuracy patterns`);
|
|
2029
|
+
}
|
|
2030
|
+
if (calibration.meta.totalScansAnalyzed < 100) {
|
|
2031
|
+
lines.push(`Calibration based on ${calibration.meta.totalScansAnalyzed} scans (limited data)`);
|
|
2032
|
+
}
|
|
2033
|
+
return lines.join("\n");
|
|
2034
|
+
}
|
|
2035
|
+
function isAggregateCalibrationDisabled() {
|
|
2036
|
+
return process.env["SECURITYCHECKS_CALIBRATION"] === "false";
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/lib/staff.ts
|
|
2040
|
+
function getStaffQuestion(invariantId) {
|
|
2041
|
+
const questions = {
|
|
2042
|
+
"AUTHZ.SERVICE_LAYER.ENFORCED": "What happens when a background job calls this function directly, bypassing the route?",
|
|
2043
|
+
"AUTHZ.MEMBERSHIP.REVOCATION.IMMEDIATE": "If I remove someone from a team right now, can they still access team resources?",
|
|
2044
|
+
"AUTHZ.KEYS.REVOCATION.IMMEDIATE": "If I revoke this API key, does it stop working immediately or is it cached?",
|
|
2045
|
+
"WEBHOOK.IDEMPOTENT": "What happens when Stripe retries this webhook? Will we double-charge the customer?",
|
|
2046
|
+
"WEBHOOK.SIGNATURE.VERIFIED": "Are we verifying webhook signatures before processing any side effects?",
|
|
2047
|
+
"TRANSACTION.POST_COMMIT.SIDE_EFFECTS": "If this transaction rolls back, did we already send an email the user will never receive?",
|
|
2048
|
+
"TESTS.NO_FALSE_CONFIDENCE": "Is this test actually verifying behavior, or just making CI green?",
|
|
2049
|
+
"CACHE.INVALIDATION.ON_AUTH_CHANGE": "When someone loses access, how long until the cache catches up?",
|
|
2050
|
+
"JOBS.RETRY_SAFE": "If this job runs twice, will we have duplicate data or double-bill someone?",
|
|
2051
|
+
"BILLING.SERVER_ENFORCED": "Can someone bypass the paywall by calling the API directly?",
|
|
2052
|
+
"ANALYTICS.SCHEMA.STABLE": "If someone adds a field here, will it break our dashboards?",
|
|
2053
|
+
"DATAFLOW.UNTRUSTED.SQL_QUERY": "Can untrusted input reach a raw SQL/NoSQL query without strict validation?",
|
|
2054
|
+
"DATAFLOW.UNTRUSTED.COMMAND_EXEC": "Can user input make it into exec/spawn/eval and change what runs?",
|
|
2055
|
+
"DATAFLOW.UNTRUSTED.FILE_ACCESS": "Can user input control file paths or write locations here?",
|
|
2056
|
+
"DATAFLOW.UNTRUSTED.RESPONSE": "Can user input drive redirects or HTML output without sanitization?",
|
|
2057
|
+
"SECRETS.HARDCODED": "If this repo were accidentally made public, what credentials would be exposed?",
|
|
2058
|
+
"CRYPTO.ALGORITHM.STRONG": "Is this encryption strong enough for the data it protects?"
|
|
2059
|
+
};
|
|
2060
|
+
return questions[invariantId] ?? null;
|
|
2061
|
+
}
|
|
2062
|
+
function generateTestSkeleton(invariant, framework, context) {
|
|
2063
|
+
if (!invariant) return "Unknown invariant";
|
|
2064
|
+
const testFn = framework === "jest" ? "test" : framework === "playwright" ? "test" : "it";
|
|
2065
|
+
const describe = framework === "playwright" ? "" : "describe";
|
|
2066
|
+
const wrap = (body) => {
|
|
2067
|
+
if (framework === "playwright") {
|
|
2068
|
+
return body.trim();
|
|
2069
|
+
}
|
|
2070
|
+
return `
|
|
2071
|
+
${describe}('${escapeString(invariant.name)}', () => {
|
|
2072
|
+
${indent(body.trim(), 2)}
|
|
2073
|
+
});
|
|
2074
|
+
`.trim();
|
|
2075
|
+
};
|
|
2076
|
+
switch (invariant.id) {
|
|
2077
|
+
case "AUTHZ.SERVICE_LAYER.ENFORCED":
|
|
2078
|
+
return wrap(`
|
|
2079
|
+
${testFn}('denies access without valid authorization', async () => {
|
|
2080
|
+
// Arrange: create context without auth
|
|
2081
|
+
const unauthorizedContext = { userId: null, tenantId: null };
|
|
2082
|
+
|
|
2083
|
+
// Act + Assert: service call should throw (or return a forbidden result)
|
|
2084
|
+
await expect(
|
|
2085
|
+
yourService.sensitiveOperation({ context: unauthorizedContext })
|
|
2086
|
+
).rejects.toThrow(/unauthorized|forbidden/i);
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
${testFn}('denies access to wrong-tenant resources', async () => {
|
|
2090
|
+
// Arrange: user from tenant-1 trying to access tenant-2 resource
|
|
2091
|
+
const context = { userId: 'user-1', tenantId: 'tenant-1' };
|
|
2092
|
+
const resourceFromOtherTenant = { id: 'resource-1', tenantId: 'tenant-2' };
|
|
2093
|
+
|
|
2094
|
+
// Act + Assert
|
|
2095
|
+
await expect(
|
|
2096
|
+
yourService.getResource({ context, resourceId: resourceFromOtherTenant.id })
|
|
2097
|
+
).rejects.toThrow(/forbidden|access denied/i);
|
|
2098
|
+
});
|
|
2099
|
+
`);
|
|
2100
|
+
case "AUTHZ.MEMBERSHIP.REVOCATION.IMMEDIATE":
|
|
2101
|
+
return wrap(`
|
|
2102
|
+
${testFn}('denies access immediately after membership removal', async () => {
|
|
2103
|
+
// Arrange: user with team membership
|
|
2104
|
+
const userId = 'user-1';
|
|
2105
|
+
const teamId = 'team-1';
|
|
2106
|
+
await addMemberToTeam(userId, teamId);
|
|
2107
|
+
|
|
2108
|
+
// Act: remove membership
|
|
2109
|
+
await removeMemberFromTeam(userId, teamId);
|
|
2110
|
+
|
|
2111
|
+
// Assert: immediate access denial (no TTL grace period)
|
|
2112
|
+
await expect(accessTeamResource({ userId, teamId })).rejects.toThrow(/forbidden|not a member/i);
|
|
2113
|
+
});
|
|
2114
|
+
`);
|
|
2115
|
+
case "WEBHOOK.IDEMPOTENT":
|
|
2116
|
+
return wrap(`
|
|
2117
|
+
${testFn}('handles duplicate webhook events idempotently', async () => {
|
|
2118
|
+
// Arrange: create a webhook event
|
|
2119
|
+
const event = { id: 'evt_test_123', type: 'payment.succeeded', data: { amount: 1000 } };
|
|
2120
|
+
|
|
2121
|
+
// Act: process the same event twice
|
|
2122
|
+
await processWebhook(event);
|
|
2123
|
+
await processWebhook(event); // duplicate
|
|
2124
|
+
|
|
2125
|
+
// Assert: side effect only happened once
|
|
2126
|
+
const payments = await getPaymentRecords();
|
|
2127
|
+
expect(payments.filter((p: any) => p.eventId === event.id)).toHaveLength(1);
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
${testFn}('stores event IDs to prevent duplicates', async () => {
|
|
2131
|
+
const event = { id: 'evt_test_456', type: 'payment.succeeded' };
|
|
2132
|
+
|
|
2133
|
+
await processWebhook(event);
|
|
2134
|
+
|
|
2135
|
+
// Verify idempotency key was stored
|
|
2136
|
+
const stored = await getProcessedEventIds();
|
|
2137
|
+
expect(stored).toContain(event.id);
|
|
2138
|
+
});
|
|
2139
|
+
`);
|
|
2140
|
+
case "TRANSACTION.POST_COMMIT.SIDE_EFFECTS":
|
|
2141
|
+
return wrap(`
|
|
2142
|
+
${testFn}('does not send side effects if transaction rolls back', async () => {
|
|
2143
|
+
const emailSpy = vi.spyOn(emailService, 'send');
|
|
2144
|
+
|
|
2145
|
+
// Act: trigger action that should fail and rollback
|
|
2146
|
+
await expect(createOrderWithInvalidData({ /* invalid data causing rollback */ })).rejects.toThrow();
|
|
2147
|
+
|
|
2148
|
+
// Assert: no email was sent
|
|
2149
|
+
expect(emailSpy).not.toHaveBeenCalled();
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
${testFn}('sends side effects only after successful commit', async () => {
|
|
2153
|
+
const emailSpy = vi.spyOn(emailService, 'send');
|
|
2154
|
+
|
|
2155
|
+
// Act: successful order creation
|
|
2156
|
+
await createOrder({ productId: 'prod-1', quantity: 1 });
|
|
2157
|
+
|
|
2158
|
+
// Assert: email was sent
|
|
2159
|
+
expect(emailSpy).toHaveBeenCalledOnce();
|
|
2160
|
+
});
|
|
2161
|
+
`);
|
|
2162
|
+
default: {
|
|
2163
|
+
const proof = invariant.requiredProof ? invariant.requiredProof : "(see invariant docs)";
|
|
2164
|
+
const contextLine = context ? `// Context: ${context}` : "";
|
|
2165
|
+
return wrap(`
|
|
2166
|
+
${testFn}('enforces ${invariant.id}', async () => {
|
|
2167
|
+
// TODO: implement test for ${invariant.id}
|
|
2168
|
+
// Required proof: ${proof}
|
|
2169
|
+
${contextLine}
|
|
2170
|
+
|
|
2171
|
+
throw new Error('Test not implemented');
|
|
2172
|
+
});
|
|
2173
|
+
`);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
function indent(text, spaces) {
|
|
2178
|
+
const prefix = " ".repeat(spaces);
|
|
2179
|
+
return text.split("\n").map((line) => line ? `${prefix}${line}` : line).join("\n");
|
|
2180
|
+
}
|
|
2181
|
+
function escapeString(text) {
|
|
2182
|
+
return text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
export { BASELINE_SCHEMA_VERSION, CLIError, ErrorCodes, ErrorMessages, ErrorRemediation, SUPPORTED_SCHEMA_RANGE, WAIVER_SCHEMA_VERSION, addToBaseline, addWaiver, attachFindingId, attachFindingIds, audit, buildTelemetry, calculateRelativeSeverity, categorizeFindings, checkCloudHealth, clearAggregateCache, correlateFindings, evaluateCloud, extractIdentityPayload, fetchAggregateCalibration, formatAggregateCalibrationSummary, formatCorrelatedFinding, formatCorrelationStats, generateFindingId, generateTestSkeleton, getCIExitCode, getCISummary, getCloudInvariants, getCurrentSchemaVersion, getExpiringWaivers, getFrameworkBaseline, getSkippedPatterns, getStaffQuestion, getValidWaiver, getVerifiedCorrelations, hasCollisions, isAggregateCalibrationDisabled, isCLIError, isCloudEvalAvailable, isInBaseline, isTelemetryDisabled, loadBaseline, loadWaivers, pruneBaseline, pruneExpiredWaivers, reportCorrelationFeedback, reportCorrelations, reportTelemetry, resolveCollisions, saveBaseline, saveWaivers, shouldSkipPattern, validateSchemaVersion, wrapError };
|
|
2186
|
+
//# sourceMappingURL=lib.js.map
|
|
2187
|
+
//# sourceMappingURL=lib.js.map
|