@apifuse/provider-sdk 2.1.0-beta.18 → 2.1.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +3 -3
- package/SUBMISSION.md +10 -11
- package/bin/apifuse-submit-check.ts +561 -279
- package/dist/cli/create.js +10 -26
- package/dist/cli/templates/provider/README.md.tpl +6 -4
- package/package.json +1 -1
- package/src/cli/create.ts +25 -93
- package/src/cli/templates/provider/README.md.tpl +6 -4
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
3
4
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
5
|
import { writeFile } from "node:fs/promises";
|
|
6
|
+
import { createServer } from "node:net";
|
|
5
7
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
8
|
import { pathToFileURL } from "node:url";
|
|
7
9
|
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
validateProviderLocaleCatalogs,
|
|
16
18
|
} from "../src/i18n";
|
|
17
19
|
import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
|
|
20
|
+
import { safeParseSchemaSync } from "../src/schema";
|
|
18
21
|
import { type CheckResult, runChecks } from "./apifuse-check";
|
|
19
22
|
|
|
20
23
|
const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
|
|
@@ -35,6 +38,7 @@ export type SubmitCheck = {
|
|
|
35
38
|
message: string;
|
|
36
39
|
remediation?: string;
|
|
37
40
|
evidence?: string[];
|
|
41
|
+
details?: unknown;
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
export type SubmitCheckReport = {
|
|
@@ -69,6 +73,7 @@ type CliArgs = {
|
|
|
69
73
|
isJson: boolean;
|
|
70
74
|
markdownPath?: string;
|
|
71
75
|
providerPath?: string;
|
|
76
|
+
smoke: boolean;
|
|
72
77
|
smokeNote?: string;
|
|
73
78
|
tier?: BountyTier;
|
|
74
79
|
};
|
|
@@ -76,6 +81,24 @@ type CliArgs = {
|
|
|
76
81
|
type SecretFinding = {
|
|
77
82
|
label: string;
|
|
78
83
|
file: string;
|
|
84
|
+
line?: number;
|
|
85
|
+
level?: CheckLevel;
|
|
86
|
+
remediation?: string;
|
|
87
|
+
evidence?: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type SmokeOperationOutcome = {
|
|
91
|
+
operationId: string;
|
|
92
|
+
status: "success" | "structured_error" | "incoherent";
|
|
93
|
+
httpStatus?: number;
|
|
94
|
+
message: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type SmokeResult = {
|
|
98
|
+
measured: true;
|
|
99
|
+
healthOk: boolean;
|
|
100
|
+
bootError?: string;
|
|
101
|
+
operations: SmokeOperationOutcome[];
|
|
79
102
|
};
|
|
80
103
|
|
|
81
104
|
type SourceFinding = {
|
|
@@ -98,14 +121,13 @@ const CATEGORY_MAX_POINTS = {
|
|
|
98
121
|
docs: 10,
|
|
99
122
|
} as const;
|
|
100
123
|
|
|
101
|
-
const REQUIRED_PUBLIC_PROVIDER_LOCALES = [
|
|
102
|
-
"en",
|
|
103
|
-
"ko",
|
|
104
|
-
] as const satisfies readonly ProviderLocale[];
|
|
124
|
+
const REQUIRED_PUBLIC_PROVIDER_LOCALES = ["en", "ko"] as const satisfies readonly ProviderLocale[];
|
|
105
125
|
|
|
106
|
-
const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke
|
|
126
|
+
const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke]
|
|
107
127
|
Alias: apifuse bounty-check [path]
|
|
108
|
-
Default: apifuse submit-check
|
|
128
|
+
Default: apifuse submit-check .
|
|
129
|
+
|
|
130
|
+
Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.`;
|
|
109
131
|
|
|
110
132
|
export async function main() {
|
|
111
133
|
try {
|
|
@@ -120,10 +142,7 @@ export async function main() {
|
|
|
120
142
|
const report = await buildSubmitCheckReport(providerRoot, args);
|
|
121
143
|
|
|
122
144
|
if (args.markdownPath) {
|
|
123
|
-
await writeFile(
|
|
124
|
-
resolve(process.cwd(), args.markdownPath),
|
|
125
|
-
renderMarkdown(report),
|
|
126
|
-
);
|
|
145
|
+
await writeFile(resolve(process.cwd(), args.markdownPath), renderMarkdown(report));
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
if (args.isJson) {
|
|
@@ -150,7 +169,7 @@ function normalizeArgs(argv: string[]): string[] {
|
|
|
150
169
|
}
|
|
151
170
|
|
|
152
171
|
function parseArgs(argv: string[]): CliArgs {
|
|
153
|
-
const args: CliArgs = { isJson: false };
|
|
172
|
+
const args: CliArgs = { isJson: false, smoke: false };
|
|
154
173
|
|
|
155
174
|
for (let index = 0; index < argv.length; index += 1) {
|
|
156
175
|
const arg = argv[index];
|
|
@@ -177,6 +196,11 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
177
196
|
continue;
|
|
178
197
|
}
|
|
179
198
|
|
|
199
|
+
if (arg === "--smoke") {
|
|
200
|
+
args.smoke = true;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
180
204
|
if (arg === "--smoke-note") {
|
|
181
205
|
args.smokeNote = requireValue(argv, index, arg);
|
|
182
206
|
index += 1;
|
|
@@ -226,9 +250,7 @@ function parseTier(value: string): BountyTier {
|
|
|
226
250
|
if (isBountyTier(value)) {
|
|
227
251
|
return value;
|
|
228
252
|
}
|
|
229
|
-
throw new Error(
|
|
230
|
-
`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`,
|
|
231
|
-
);
|
|
253
|
+
throw new Error(`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`);
|
|
232
254
|
}
|
|
233
255
|
|
|
234
256
|
function isBountyTier(value: string): value is BountyTier {
|
|
@@ -237,7 +259,7 @@ function isBountyTier(value: string): value is BountyTier {
|
|
|
237
259
|
|
|
238
260
|
export async function buildSubmitCheckReport(
|
|
239
261
|
providerRoot: string,
|
|
240
|
-
args: { smokeNote?: string; tier?: BountyTier } = {},
|
|
262
|
+
args: { smoke?: boolean; smokeNote?: string; tier?: BountyTier } = {},
|
|
241
263
|
): Promise<SubmitCheckReport> {
|
|
242
264
|
const checks: SubmitCheck[] = [];
|
|
243
265
|
const baseChecks = await safeRunChecks(providerRoot);
|
|
@@ -257,16 +279,17 @@ export async function buildSubmitCheckReport(
|
|
|
257
279
|
checks.push(scoreFlatOperationComposition(providerRoot));
|
|
258
280
|
|
|
259
281
|
if (provider) {
|
|
282
|
+
const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
|
|
260
283
|
checks.push(scoreCredentialUsage(providerRoot, provider));
|
|
261
284
|
checks.push(scoreLocaleCatalog(providerRoot, provider));
|
|
262
285
|
checks.push(scoreOperationMetadata(provider));
|
|
263
286
|
checks.push(scoreFixtureCoverage(provider));
|
|
264
287
|
checks.push(scoreHealthCoverage(provider));
|
|
265
288
|
checks.push(scoreAuthSafety(provider));
|
|
266
|
-
checks.push(
|
|
289
|
+
checks.push(scoreSmoke(smokeResult, args.smokeNote));
|
|
267
290
|
checks.push(...scoreProviderDocs(providerRoot));
|
|
268
291
|
checks.push(scoreRepositoryDx(providerRoot));
|
|
269
|
-
checks.push(scoreSecrets(providerRoot));
|
|
292
|
+
checks.push(scoreSecrets(providerRoot, provider));
|
|
270
293
|
} else {
|
|
271
294
|
checks.push(
|
|
272
295
|
blocker(
|
|
@@ -279,22 +302,14 @@ export async function buildSubmitCheckReport(
|
|
|
279
302
|
);
|
|
280
303
|
}
|
|
281
304
|
|
|
282
|
-
const total = clamp(
|
|
283
|
-
Math.round(checks.reduce((sum, check) => sum + check.points, 0)),
|
|
284
|
-
0,
|
|
285
|
-
100,
|
|
286
|
-
);
|
|
305
|
+
const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
|
|
287
306
|
const blockers = checks.filter(
|
|
288
307
|
(check) => check.level === "blocker" && check.status === "fail",
|
|
289
308
|
).length;
|
|
290
309
|
const warnings = checks.filter((check) => check.status === "warn").length;
|
|
291
310
|
const passed = checks.filter((check) => check.status === "pass").length;
|
|
292
311
|
const verdict: Verdict =
|
|
293
|
-
blockers > 0
|
|
294
|
-
? "blocked"
|
|
295
|
-
: total >= 90 && warnings === 0
|
|
296
|
-
? "ready"
|
|
297
|
-
: "reviewable_with_warnings";
|
|
312
|
+
blockers > 0 ? "blocked" : total >= 90 && warnings === 0 ? "ready" : "reviewable_with_warnings";
|
|
298
313
|
|
|
299
314
|
return {
|
|
300
315
|
schemaVersion: 1,
|
|
@@ -335,18 +350,10 @@ function scoreProviderIdSlug(
|
|
|
335
350
|
);
|
|
336
351
|
}
|
|
337
352
|
|
|
338
|
-
return pass(
|
|
339
|
-
"id-slug",
|
|
340
|
-
SDK_NATIVE_CATEGORY,
|
|
341
|
-
"Provider id uses the short slug.",
|
|
342
|
-
0,
|
|
343
|
-
);
|
|
353
|
+
return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
|
|
344
354
|
}
|
|
345
355
|
|
|
346
|
-
const findings = findSourceLineMatches(
|
|
347
|
-
providerRoot,
|
|
348
|
-
/["'`]apifuse-provider-[a-z0-9-]/i,
|
|
349
|
-
);
|
|
356
|
+
const findings = findSourceLineMatches(providerRoot, /["'`]apifuse-provider-[a-z0-9-]/i);
|
|
350
357
|
if (findings.length > 0) {
|
|
351
358
|
return blocker(
|
|
352
359
|
"id-slug",
|
|
@@ -358,12 +365,7 @@ function scoreProviderIdSlug(
|
|
|
358
365
|
);
|
|
359
366
|
}
|
|
360
367
|
|
|
361
|
-
return pass(
|
|
362
|
-
"id-slug",
|
|
363
|
-
SDK_NATIVE_CATEGORY,
|
|
364
|
-
"Provider id uses the short slug.",
|
|
365
|
-
0,
|
|
366
|
-
);
|
|
368
|
+
return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
|
|
367
369
|
}
|
|
368
370
|
|
|
369
371
|
function scoreNoVendorShim(providerRoot: string): SubmitCheck {
|
|
@@ -388,10 +390,7 @@ function scoreNoVendorShim(providerRoot: string): SubmitCheck {
|
|
|
388
390
|
}
|
|
389
391
|
|
|
390
392
|
function scoreNoVendorImport(providerRoot: string): SubmitCheck {
|
|
391
|
-
const findings = findSourceLineMatches(
|
|
392
|
-
providerRoot,
|
|
393
|
-
/from\s+["'][^"']*vendor\//,
|
|
394
|
-
);
|
|
393
|
+
const findings = findSourceLineMatches(providerRoot, /from\s+["'][^"']*vendor\//);
|
|
395
394
|
if (findings.length > 0) {
|
|
396
395
|
return blocker(
|
|
397
396
|
"no-vendor-import",
|
|
@@ -424,12 +423,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
|
|
|
424
423
|
);
|
|
425
424
|
}
|
|
426
425
|
|
|
427
|
-
return pass(
|
|
428
|
-
"describe-key",
|
|
429
|
-
SDK_NATIVE_CATEGORY,
|
|
430
|
-
"Schema descriptions use describeKey.",
|
|
431
|
-
0,
|
|
432
|
-
);
|
|
426
|
+
return pass("describe-key", SDK_NATIVE_CATEGORY, "Schema descriptions use describeKey.", 0);
|
|
433
427
|
}
|
|
434
428
|
|
|
435
429
|
function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
@@ -446,12 +440,7 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
|
446
440
|
);
|
|
447
441
|
}
|
|
448
442
|
|
|
449
|
-
return pass(
|
|
450
|
-
"no-raw-fetch",
|
|
451
|
-
SDK_NATIVE_CATEGORY,
|
|
452
|
-
"Provider source avoids raw fetch().",
|
|
453
|
-
0,
|
|
454
|
-
);
|
|
443
|
+
return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
|
|
455
444
|
}
|
|
456
445
|
|
|
457
446
|
const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
@@ -461,10 +450,7 @@ const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
|
461
450
|
const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
|
|
462
451
|
/\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
|
|
463
452
|
|
|
464
|
-
function hasRedundantRuntimeGuard(
|
|
465
|
-
line: string,
|
|
466
|
-
remainingLines: readonly string[],
|
|
467
|
-
): boolean {
|
|
453
|
+
function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
|
|
468
454
|
if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
|
|
469
455
|
return true;
|
|
470
456
|
}
|
|
@@ -475,12 +461,8 @@ function hasRedundantRuntimeGuard(
|
|
|
475
461
|
return false;
|
|
476
462
|
}
|
|
477
463
|
|
|
478
|
-
const guardPattern = new RegExp(
|
|
479
|
-
|
|
480
|
-
);
|
|
481
|
-
return remainingLines
|
|
482
|
-
.slice(0, 8)
|
|
483
|
-
.some((candidate) => guardPattern.test(candidate));
|
|
464
|
+
const guardPattern = new RegExp(`(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`);
|
|
465
|
+
return remainingLines.slice(0, 8).some((candidate) => guardPattern.test(candidate));
|
|
484
466
|
}
|
|
485
467
|
|
|
486
468
|
function scoreNoRedundantRuntimeGuards(providerRoot: string): SubmitCheck {
|
|
@@ -581,11 +563,7 @@ function scoreAsAssertionCount(providerRoot: string): SubmitCheck {
|
|
|
581
563
|
|
|
582
564
|
// Returns true when `findingLine` (1-based) or the line directly above it
|
|
583
565
|
// carries an `// @apifuse-allow <ruleId>:` acknowledgement comment.
|
|
584
|
-
function hasAllowOverride(
|
|
585
|
-
lines: readonly string[],
|
|
586
|
-
findingLine: number,
|
|
587
|
-
ruleId: string,
|
|
588
|
-
): boolean {
|
|
566
|
+
function hasAllowOverride(lines: readonly string[], findingLine: number, ruleId: string): boolean {
|
|
589
567
|
const pattern = new RegExp(`@apifuse-allow\\s+${ruleId}\\b`);
|
|
590
568
|
const current = lines[findingLine - 1];
|
|
591
569
|
const previous = lines[findingLine - 2];
|
|
@@ -636,11 +614,7 @@ function escapeHatchResult(
|
|
|
636
614
|
return pass(ruleId, SDK_NATIVE_CATEGORY, copy.passMessage, 0);
|
|
637
615
|
}
|
|
638
616
|
|
|
639
|
-
const { violations, overridden } = partitionAllowOverrides(
|
|
640
|
-
providerRoot,
|
|
641
|
-
findings,
|
|
642
|
-
ruleId,
|
|
643
|
-
);
|
|
617
|
+
const { violations, overridden } = partitionAllowOverrides(providerRoot, findings, ruleId);
|
|
644
618
|
|
|
645
619
|
if (violations.length > 0) {
|
|
646
620
|
return blocker(
|
|
@@ -953,9 +927,7 @@ function inputKeyIsSchemaField(source: string, propIndex: number): boolean {
|
|
|
953
927
|
// across unrelated modules from producing false positives).
|
|
954
928
|
function fileImportsBinding(source: string, name: string): boolean {
|
|
955
929
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
956
|
-
return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(
|
|
957
|
-
source,
|
|
958
|
-
);
|
|
930
|
+
return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(source);
|
|
959
931
|
}
|
|
960
932
|
|
|
961
933
|
// Resolves the ORIGINAL exported name for a local binding `localName`. When the
|
|
@@ -999,11 +971,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
999
971
|
passthroughByFile.set(filePath, localMap);
|
|
1000
972
|
const constDecl =
|
|
1001
973
|
/(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
|
|
1002
|
-
for (
|
|
1003
|
-
let match = constDecl.exec(source);
|
|
1004
|
-
match !== null;
|
|
1005
|
-
match = constDecl.exec(source)
|
|
1006
|
-
) {
|
|
974
|
+
for (let match = constDecl.exec(source); match !== null; match = constDecl.exec(source)) {
|
|
1007
975
|
const name = match[1];
|
|
1008
976
|
if (name === undefined) {
|
|
1009
977
|
continue;
|
|
@@ -1042,11 +1010,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1042
1010
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1043
1011
|
|
|
1044
1012
|
const inputProp = /\binput\s*:\s*/g;
|
|
1045
|
-
for (
|
|
1046
|
-
let match = inputProp.exec(source);
|
|
1047
|
-
match !== null;
|
|
1048
|
-
match = inputProp.exec(source)
|
|
1049
|
-
) {
|
|
1013
|
+
for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
|
|
1050
1014
|
// Skip `input` keys that are fields inside a zod schema body (e.g. an
|
|
1051
1015
|
// upstream payload modelled as `z.object({ input: ... })`). Only an
|
|
1052
1016
|
// operation's public `input:` property is in scope for this rule.
|
|
@@ -1074,9 +1038,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1074
1038
|
// Imported binding: map a possible `orig as refName` alias
|
|
1075
1039
|
// back to the exported name the provider-wide map is keyed by.
|
|
1076
1040
|
const originalName = importedOriginalName(source, refName);
|
|
1077
|
-
const site =
|
|
1078
|
-
passthroughConsts.get(refName) ??
|
|
1079
|
-
passthroughConsts.get(originalName);
|
|
1041
|
+
const site = passthroughConsts.get(refName) ?? passthroughConsts.get(originalName);
|
|
1080
1042
|
if (site) {
|
|
1081
1043
|
push(site);
|
|
1082
1044
|
}
|
|
@@ -1122,8 +1084,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
|
|
|
1122
1084
|
// A `//` justification comment on the same line or the line above
|
|
1123
1085
|
// (including the `@apifuse-allow loose-schema:` form) acknowledges it.
|
|
1124
1086
|
const previous = lines[index - 1];
|
|
1125
|
-
const justified =
|
|
1126
|
-
line.includes("//") || previous?.trim().startsWith("//") === true;
|
|
1087
|
+
const justified = line.includes("//") || previous?.trim().startsWith("//") === true;
|
|
1127
1088
|
if (!justified) {
|
|
1128
1089
|
findings.push({
|
|
1129
1090
|
file: toRelativeProviderPath(providerRoot, filePath),
|
|
@@ -1134,8 +1095,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
|
|
|
1134
1095
|
}
|
|
1135
1096
|
|
|
1136
1097
|
return escapeHatchResult(providerRoot, "unjustified-loose-schema", findings, {
|
|
1137
|
-
blockerMessage:
|
|
1138
|
-
"Loose schema (z.record/z.unknown/z.any) used without justification.",
|
|
1098
|
+
blockerMessage: "Loose schema (z.record/z.unknown/z.any) used without justification.",
|
|
1139
1099
|
remediation:
|
|
1140
1100
|
"Model the real shape with a typed zod schema. If the upstream payload is genuinely arbitrary, add a `// <reason>` comment or `// @apifuse-allow loose-schema: <reason>` on the line above.",
|
|
1141
1101
|
passMessage: "Loose schemas are justified or absent.",
|
|
@@ -1164,24 +1124,18 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1164
1124
|
let sawDeclaration = false;
|
|
1165
1125
|
for (const filePath of [
|
|
1166
1126
|
indexPath,
|
|
1167
|
-
...listNonTestTypeScriptFiles(providerRoot).filter(
|
|
1168
|
-
(p) => resolve(p) !== resolve(indexPath),
|
|
1169
|
-
),
|
|
1127
|
+
...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
|
|
1170
1128
|
]) {
|
|
1171
1129
|
if (!existsSync(filePath)) {
|
|
1172
1130
|
continue;
|
|
1173
1131
|
}
|
|
1174
|
-
const fileSource =
|
|
1175
|
-
filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1132
|
+
const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1176
1133
|
const re = new RegExp(declRe.source, "g");
|
|
1177
1134
|
for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
|
|
1178
1135
|
sawDeclaration = true;
|
|
1179
|
-
const expr = unwrapParens(
|
|
1180
|
-
balancedValueExpression(fileSource, m.index + m[0].length).trim(),
|
|
1181
|
-
);
|
|
1136
|
+
const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
|
|
1182
1137
|
const isFactory =
|
|
1183
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
|
|
1184
|
-
hasTopLevelFactorySpread(expr)) &&
|
|
1138
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1185
1139
|
!isTransparentObjectReshape(expr);
|
|
1186
1140
|
if (isFactory) {
|
|
1187
1141
|
return true;
|
|
@@ -1233,9 +1187,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1233
1187
|
if (inlineDefault) {
|
|
1234
1188
|
defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
|
|
1235
1189
|
} else {
|
|
1236
|
-
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(
|
|
1237
|
-
source,
|
|
1238
|
-
);
|
|
1190
|
+
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
|
|
1239
1191
|
const exportedName = namedDefault?.[1];
|
|
1240
1192
|
if (exportedName !== undefined) {
|
|
1241
1193
|
const namedDecl = new RegExp(
|
|
@@ -1309,9 +1261,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1309
1261
|
// Search index.ts first (its line attribution wins), then siblings.
|
|
1310
1262
|
const searchOrder = [
|
|
1311
1263
|
indexPath,
|
|
1312
|
-
...listNonTestTypeScriptFiles(providerRoot).filter(
|
|
1313
|
-
(p) => resolve(p) !== resolve(indexPath),
|
|
1314
|
-
),
|
|
1264
|
+
...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
|
|
1315
1265
|
];
|
|
1316
1266
|
|
|
1317
1267
|
// Collect EVERY same-named declaration across the submission and classify
|
|
@@ -1331,23 +1281,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1331
1281
|
if (!existsSync(filePath)) {
|
|
1332
1282
|
continue;
|
|
1333
1283
|
}
|
|
1334
|
-
const fileSource =
|
|
1335
|
-
filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1284
|
+
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1336
1285
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1337
1286
|
|
|
1338
1287
|
const declRe = new RegExp(aliasDecl.source, "g");
|
|
1339
|
-
for (
|
|
1340
|
-
let m = declRe.exec(fileSource);
|
|
1341
|
-
m !== null;
|
|
1342
|
-
m = declRe.exec(fileSource)
|
|
1343
|
-
) {
|
|
1288
|
+
for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
|
|
1344
1289
|
const valueStart = m.index + m[0].length;
|
|
1345
|
-
const expr = unwrapParens(
|
|
1346
|
-
balancedValueExpression(fileSource, valueStart).trim(),
|
|
1347
|
-
);
|
|
1290
|
+
const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
|
|
1348
1291
|
const isFactory =
|
|
1349
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
|
|
1350
|
-
hasTopLevelFactorySpread(expr)) &&
|
|
1292
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1351
1293
|
!isTransparentObjectReshape(expr);
|
|
1352
1294
|
candidates.push({
|
|
1353
1295
|
expr,
|
|
@@ -1357,11 +1299,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1357
1299
|
});
|
|
1358
1300
|
}
|
|
1359
1301
|
const destructRe = new RegExp(destructured.source, "g");
|
|
1360
|
-
for (
|
|
1361
|
-
let m = destructRe.exec(fileSource);
|
|
1362
|
-
m !== null;
|
|
1363
|
-
m = destructRe.exec(fileSource)
|
|
1364
|
-
) {
|
|
1302
|
+
for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
|
|
1365
1303
|
candidates.push({
|
|
1366
1304
|
expr: `${m[1]}(`,
|
|
1367
1305
|
line: offsetToLine(fileSource, m.index),
|
|
@@ -1389,9 +1327,9 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1389
1327
|
// the unresolved import as a factory-composed (non-static) shape rather
|
|
1390
1328
|
// than silently passing.
|
|
1391
1329
|
if (!resolved) {
|
|
1392
|
-
const importMatch = new RegExp(
|
|
1393
|
-
|
|
1394
|
-
)
|
|
1330
|
+
const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
|
|
1331
|
+
source,
|
|
1332
|
+
);
|
|
1395
1333
|
if (importMatch) {
|
|
1396
1334
|
effective = `${aliasName}(`;
|
|
1397
1335
|
effectiveLine = offsetToLine(source, importMatch.index);
|
|
@@ -1406,8 +1344,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1406
1344
|
// inspect depth-1 entries so that ordinary spreads deep inside operation
|
|
1407
1345
|
// handler bodies (e.g. `{ ...headers }`, `...arr.map(...)`) are NOT mistaken
|
|
1408
1346
|
// for a top-level factory composition of the operations map itself.
|
|
1409
|
-
const hasFactorySpread =
|
|
1410
|
-
effective !== undefined && hasTopLevelFactorySpread(effective);
|
|
1347
|
+
const hasFactorySpread = effective !== undefined && hasTopLevelFactorySpread(effective);
|
|
1411
1348
|
// A spread of a bare identifier (`{ ...hidden }`) is static ONLY when that
|
|
1412
1349
|
// identifier resolves to a non-factory declaration. Resolve each top-level
|
|
1413
1350
|
// spread identifier so an opaque factory map laundered through a variable
|
|
@@ -1418,18 +1355,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1418
1355
|
spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
|
|
1419
1356
|
);
|
|
1420
1357
|
const isStaticLiteral =
|
|
1421
|
-
effective?.startsWith("{") === true &&
|
|
1422
|
-
!hasFactorySpread &&
|
|
1423
|
-
!hasFactorySpreadIdentifier;
|
|
1358
|
+
effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
1424
1359
|
// A call expression `ident(...)` (factory) or a factory-spread literal is
|
|
1425
1360
|
// the rejected, non-static shape — UNLESS it is the stdlib
|
|
1426
1361
|
// `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
|
|
1427
1362
|
// whose op set is still enumerable from source (verified golden pattern).
|
|
1428
1363
|
const isFactoryCall =
|
|
1429
1364
|
effective !== undefined &&
|
|
1430
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
|
|
1431
|
-
hasFactorySpread ||
|
|
1432
|
-
hasFactorySpreadIdentifier) &&
|
|
1365
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
|
|
1433
1366
|
!isTransparentObjectReshape(effective);
|
|
1434
1367
|
|
|
1435
1368
|
if (isFactoryCall && !isStaticLiteral) {
|
|
@@ -1437,19 +1370,13 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1437
1370
|
// `// @apifuse-allow flat-operation-composition: <reason>` comment on
|
|
1438
1371
|
// the reported line (or the line above) downgrades this blocker to a
|
|
1439
1372
|
// counted warning, consistent with the other structural rules.
|
|
1440
|
-
return escapeHatchResult(
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
remediation:
|
|
1448
|
-
"Declare operations as a static literal: defineProvider({ operations: { 'op-id': defineOperation({...}) } }). The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
|
|
1449
|
-
passMessage:
|
|
1450
|
-
"defineProvider declares operations as a static object literal.",
|
|
1451
|
-
},
|
|
1452
|
-
);
|
|
1373
|
+
return escapeHatchResult(providerRoot, ruleId, [{ file: effectiveFile, line: effectiveLine }], {
|
|
1374
|
+
blockerMessage:
|
|
1375
|
+
"defineProvider operations are composed by a factory call instead of a static object literal.",
|
|
1376
|
+
remediation:
|
|
1377
|
+
"Declare operations as a static literal: defineProvider({ operations: { 'op-id': defineOperation({...}) } }). The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
|
|
1378
|
+
passMessage: "defineProvider declares operations as a static object literal.",
|
|
1379
|
+
});
|
|
1453
1380
|
}
|
|
1454
1381
|
|
|
1455
1382
|
return pass(
|
|
@@ -1460,18 +1387,11 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1460
1387
|
);
|
|
1461
1388
|
}
|
|
1462
1389
|
|
|
1463
|
-
function scoreCredentialUsage(
|
|
1464
|
-
providerRoot
|
|
1465
|
-
provider: ProviderDefinition,
|
|
1466
|
-
): SubmitCheck {
|
|
1467
|
-
const credentialReferences = findSourceLineMatches(
|
|
1468
|
-
providerRoot,
|
|
1469
|
-
/ctx\.credential/,
|
|
1470
|
-
);
|
|
1390
|
+
function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1391
|
+
const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
|
|
1471
1392
|
const authMode = provider.auth?.mode ?? "none";
|
|
1472
1393
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
1473
|
-
const storesProviderCredential =
|
|
1474
|
-
authMode !== "none" || credentialKeys.length > 0;
|
|
1394
|
+
const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
|
|
1475
1395
|
|
|
1476
1396
|
if (storesProviderCredential && credentialReferences.length === 0) {
|
|
1477
1397
|
return {
|
|
@@ -1481,8 +1401,7 @@ function scoreCredentialUsage(
|
|
|
1481
1401
|
status: "warn",
|
|
1482
1402
|
points: 0,
|
|
1483
1403
|
maxPoints: 0,
|
|
1484
|
-
message:
|
|
1485
|
-
"Credential-backed provider does not reference credential persistence in source.",
|
|
1404
|
+
message: "Credential-backed provider does not reference credential persistence in source.",
|
|
1486
1405
|
remediation:
|
|
1487
1406
|
"Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
|
|
1488
1407
|
};
|
|
@@ -1495,9 +1414,7 @@ function scoreCredentialUsage(
|
|
|
1495
1414
|
? "Provider does not declare reusable credentials."
|
|
1496
1415
|
: "Credential-backed provider references ctx.credential.",
|
|
1497
1416
|
0,
|
|
1498
|
-
credentialReferences.length > 0
|
|
1499
|
-
? formatSourceFindings(credentialReferences)
|
|
1500
|
-
: undefined,
|
|
1417
|
+
credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
|
|
1501
1418
|
);
|
|
1502
1419
|
}
|
|
1503
1420
|
|
|
@@ -1505,9 +1422,7 @@ function findSourceLineMatches(
|
|
|
1505
1422
|
providerRoot: string,
|
|
1506
1423
|
pattern: RegExp | ((line: string) => boolean),
|
|
1507
1424
|
): SourceFinding[] {
|
|
1508
|
-
return findSourceFindings(providerRoot, (line) =>
|
|
1509
|
-
matchesLinePattern(line, pattern),
|
|
1510
|
-
);
|
|
1425
|
+
return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
|
|
1511
1426
|
}
|
|
1512
1427
|
|
|
1513
1428
|
function findSourceFindings(
|
|
@@ -1534,10 +1449,7 @@ function findSourceFindings(
|
|
|
1534
1449
|
return findings;
|
|
1535
1450
|
}
|
|
1536
1451
|
|
|
1537
|
-
function matchesLinePattern(
|
|
1538
|
-
line: string,
|
|
1539
|
-
pattern: RegExp | ((line: string) => boolean),
|
|
1540
|
-
): boolean {
|
|
1452
|
+
function matchesLinePattern(line: string, pattern: RegExp | ((line: string) => boolean)): boolean {
|
|
1541
1453
|
return typeof pattern === "function" ? pattern(line) : pattern.test(line);
|
|
1542
1454
|
}
|
|
1543
1455
|
|
|
@@ -1591,11 +1503,7 @@ function collectNonTestTypeScriptFiles(
|
|
|
1591
1503
|
}
|
|
1592
1504
|
continue;
|
|
1593
1505
|
}
|
|
1594
|
-
if (
|
|
1595
|
-
entry.isFile() &&
|
|
1596
|
-
relativePath.endsWith(".ts") &&
|
|
1597
|
-
!isExcludedTestSource(relativePath)
|
|
1598
|
-
) {
|
|
1506
|
+
if (entry.isFile() && relativePath.endsWith(".ts") && !isExcludedTestSource(relativePath)) {
|
|
1599
1507
|
files.push(entryPath);
|
|
1600
1508
|
}
|
|
1601
1509
|
}
|
|
@@ -1610,9 +1518,7 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
|
|
|
1610
1518
|
}
|
|
1611
1519
|
|
|
1612
1520
|
function shouldScanSourceDirectory(relativePath: string): boolean {
|
|
1613
|
-
return ![".git", "node_modules", "dist", "build", "coverage"].includes(
|
|
1614
|
-
relativePath,
|
|
1615
|
-
);
|
|
1521
|
+
return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
|
|
1616
1522
|
}
|
|
1617
1523
|
|
|
1618
1524
|
function isExcludedTestSource(relativePath: string): boolean {
|
|
@@ -1625,10 +1531,7 @@ function isExcludedTestSource(relativePath: string): boolean {
|
|
|
1625
1531
|
);
|
|
1626
1532
|
}
|
|
1627
1533
|
|
|
1628
|
-
function toRelativeProviderPath(
|
|
1629
|
-
providerRoot: string,
|
|
1630
|
-
filePath: string,
|
|
1631
|
-
): string {
|
|
1534
|
+
function toRelativeProviderPath(providerRoot: string, filePath: string): string {
|
|
1632
1535
|
return relative(providerRoot, filePath).replaceAll("\\", "/");
|
|
1633
1536
|
}
|
|
1634
1537
|
|
|
@@ -1674,9 +1577,7 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
|
|
|
1674
1577
|
};
|
|
1675
1578
|
}
|
|
1676
1579
|
|
|
1677
|
-
function readPackageScripts(
|
|
1678
|
-
packageJsonPath: string,
|
|
1679
|
-
): Record<string, unknown> | undefined {
|
|
1580
|
+
function readPackageScripts(packageJsonPath: string): Record<string, unknown> | undefined {
|
|
1680
1581
|
if (!existsSync(packageJsonPath)) {
|
|
1681
1582
|
return undefined;
|
|
1682
1583
|
}
|
|
@@ -1851,10 +1752,7 @@ function baseCheckRemediation(result: CheckResult): string {
|
|
|
1851
1752
|
}
|
|
1852
1753
|
}
|
|
1853
1754
|
|
|
1854
|
-
function scoreLocaleCatalog(
|
|
1855
|
-
providerRoot: string,
|
|
1856
|
-
provider: ProviderDefinition,
|
|
1857
|
-
): SubmitCheck {
|
|
1755
|
+
function scoreLocaleCatalog(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1858
1756
|
const requiredKeys = collectProviderRequiredLocaleKeys(provider);
|
|
1859
1757
|
if (requiredKeys.length === 0) {
|
|
1860
1758
|
return pass(
|
|
@@ -1885,9 +1783,7 @@ function scoreLocaleCatalog(
|
|
|
1885
1783
|
"Provider locale catalog is missing required public-provider copy.",
|
|
1886
1784
|
"Add provider-local locales/en.json and locales/ko.json values for every provider metadata key, operation descriptionKey, and .describeKey() or describeKey() schema field.",
|
|
1887
1785
|
0,
|
|
1888
|
-
validation.issues.map(
|
|
1889
|
-
(issue) => `${issue.locale}:${issue.key}: ${issue.message}`,
|
|
1890
|
-
),
|
|
1786
|
+
validation.issues.map((issue) => `${issue.locale}:${issue.key}: ${issue.message}`),
|
|
1891
1787
|
);
|
|
1892
1788
|
}
|
|
1893
1789
|
} catch (error) {
|
|
@@ -1910,9 +1806,7 @@ function scoreLocaleCatalog(
|
|
|
1910
1806
|
);
|
|
1911
1807
|
}
|
|
1912
1808
|
|
|
1913
|
-
function collectProviderRequiredLocaleKeys(
|
|
1914
|
-
provider: ProviderDefinition,
|
|
1915
|
-
): string[] {
|
|
1809
|
+
function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string[] {
|
|
1916
1810
|
const keys = new Set<string>();
|
|
1917
1811
|
|
|
1918
1812
|
addLocaleKeys(keys, [
|
|
@@ -1975,10 +1869,7 @@ function collectSchemaDescriptionKeys(schema: unknown): string[] {
|
|
|
1975
1869
|
return keys;
|
|
1976
1870
|
}
|
|
1977
1871
|
|
|
1978
|
-
function collectJsonSchemaDescriptionKeys(
|
|
1979
|
-
schema: Record<string, unknown>,
|
|
1980
|
-
keys: string[],
|
|
1981
|
-
): void {
|
|
1872
|
+
function collectJsonSchemaDescriptionKeys(schema: Record<string, unknown>, keys: string[]): void {
|
|
1982
1873
|
const descriptionKey = schema[APIFUSE_DESCRIPTION_KEY_META_KEY];
|
|
1983
1874
|
if (typeof descriptionKey === "string" && descriptionKey.length > 0) {
|
|
1984
1875
|
keys.push(descriptionKey);
|
|
@@ -2010,8 +1901,7 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2010
1901
|
// is enforced at registry catalog-build time, matching how lintOperation
|
|
2011
1902
|
// skips the raw-description min-length rule when a descriptionKey is set.
|
|
2012
1903
|
const hasDescriptionKey =
|
|
2013
|
-
typeof operation.descriptionKey === "string" &&
|
|
2014
|
-
operation.descriptionKey.length > 0;
|
|
1904
|
+
typeof operation.descriptionKey === "string" && operation.descriptionKey.length > 0;
|
|
2015
1905
|
if (hasDescriptionKey) return false;
|
|
2016
1906
|
return true;
|
|
2017
1907
|
})
|
|
@@ -2029,14 +1919,12 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2029
1919
|
points: 0,
|
|
2030
1920
|
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
2031
1921
|
message: "One or more operations have weak descriptions.",
|
|
2032
|
-
remediation:
|
|
2033
|
-
`For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`, or add a 150+ character \`description\` explaining when to use it, when not to use it, outputs, and caveats.`,
|
|
1922
|
+
remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`, or add a 150+ character \`description\` explaining when to use it, when not to use it, outputs, and caveats.`,
|
|
2034
1923
|
evidence: weakDescriptions,
|
|
2035
1924
|
};
|
|
2036
1925
|
}
|
|
2037
1926
|
|
|
2038
|
-
const points =
|
|
2039
|
-
missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
1927
|
+
const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
2040
1928
|
return {
|
|
2041
1929
|
id: "operation-metadata",
|
|
2042
1930
|
category: "operations",
|
|
@@ -2054,19 +1942,14 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2054
1942
|
: undefined,
|
|
2055
1943
|
evidence:
|
|
2056
1944
|
missingAnnotations.length > 0
|
|
2057
|
-
? missingAnnotations.map(
|
|
2058
|
-
(operationId) => `${operationId}: missing annotations`,
|
|
2059
|
-
)
|
|
1945
|
+
? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
|
|
2060
1946
|
: operations.map(([operationId]) => operationId),
|
|
2061
1947
|
};
|
|
2062
1948
|
}
|
|
2063
1949
|
|
|
2064
1950
|
function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
2065
1951
|
const missing = Object.entries(provider.operations)
|
|
2066
|
-
.filter(
|
|
2067
|
-
([, operation]) =>
|
|
2068
|
-
!operation.fixtures?.request || !operation.fixtures?.response,
|
|
2069
|
-
)
|
|
1952
|
+
.filter(([, operation]) => !operation.fixtures?.request || !operation.fixtures?.response)
|
|
2070
1953
|
.map(([operationId]) => operationId);
|
|
2071
1954
|
if (missing.length > 0) {
|
|
2072
1955
|
return blocker(
|
|
@@ -2107,9 +1990,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2107
1990
|
generatedStarter.push(operationId);
|
|
2108
1991
|
}
|
|
2109
1992
|
if (
|
|
2110
|
-
/(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(
|
|
2111
|
-
reason,
|
|
2112
|
-
)
|
|
1993
|
+
/(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(reason)
|
|
2113
1994
|
) {
|
|
2114
1995
|
placeholder.push(operationId);
|
|
2115
1996
|
}
|
|
@@ -2136,8 +2017,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2136
2017
|
points: 8,
|
|
2137
2018
|
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2138
2019
|
message: "Some healthCheckUnsupported reasons look placeholder-like.",
|
|
2139
|
-
remediation:
|
|
2140
|
-
`For ${placeholder.join(", ")}, replace the placeholder \`healthCheckUnsupported.reason\` with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.`,
|
|
2020
|
+
remediation: `For ${placeholder.join(", ")}, replace the placeholder \`healthCheckUnsupported.reason\` with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.`,
|
|
2141
2021
|
evidence: placeholder,
|
|
2142
2022
|
};
|
|
2143
2023
|
}
|
|
@@ -2152,8 +2032,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2152
2032
|
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2153
2033
|
message:
|
|
2154
2034
|
"Generated starter operation health rationale is present; replace starter logic before bounty submission.",
|
|
2155
|
-
remediation:
|
|
2156
|
-
`Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
|
|
2035
|
+
remediation: `Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
|
|
2157
2036
|
evidence: generatedStarter,
|
|
2158
2037
|
};
|
|
2159
2038
|
}
|
|
@@ -2166,13 +2045,9 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2166
2045
|
status: "warn",
|
|
2167
2046
|
points: 12,
|
|
2168
2047
|
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2169
|
-
message:
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
`For ${unsupported.join(", ")}, replace \`healthCheckUnsupported\` with \`healthCheck: { interval, cases }\` when the upstream operation is safe and read-only; keep unsupported only for destructive, paid, credential-sensitive, or flaky probes with a specific reason.`,
|
|
2173
|
-
evidence: unsupported.map(
|
|
2174
|
-
(operationId) => `${operationId}: healthCheckUnsupported`,
|
|
2175
|
-
),
|
|
2048
|
+
message: "Health coverage is declared, with one or more unsupported probes.",
|
|
2049
|
+
remediation: `For ${unsupported.join(", ")}, replace \`healthCheckUnsupported\` with \`healthCheck: { interval, cases }\` when the upstream operation is safe and read-only; keep unsupported only for destructive, paid, credential-sensitive, or flaky probes with a specific reason.`,
|
|
2050
|
+
evidence: unsupported.map((operationId) => `${operationId}: healthCheckUnsupported`),
|
|
2176
2051
|
};
|
|
2177
2052
|
}
|
|
2178
2053
|
|
|
@@ -2184,8 +2059,55 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2184
2059
|
);
|
|
2185
2060
|
}
|
|
2186
2061
|
|
|
2187
|
-
function
|
|
2188
|
-
|
|
2062
|
+
function scoreSmoke(
|
|
2063
|
+
smokeResult: SmokeResult | undefined,
|
|
2064
|
+
smokeNote: string | undefined,
|
|
2065
|
+
): SubmitCheck {
|
|
2066
|
+
const deprecatedEvidence = smokeNote?.trim()
|
|
2067
|
+
? ["Deprecated --smoke-note was provided and ignored for scoring."]
|
|
2068
|
+
: [];
|
|
2069
|
+
if (!smokeResult) {
|
|
2070
|
+
return {
|
|
2071
|
+
id: "local-smoke",
|
|
2072
|
+
category: "smoke",
|
|
2073
|
+
level: "warn",
|
|
2074
|
+
status: "warn",
|
|
2075
|
+
points: 0,
|
|
2076
|
+
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
2077
|
+
message: "Measured local smoke was not run.",
|
|
2078
|
+
remediation:
|
|
2079
|
+
"Rerun submit-check with `--smoke` so it boots the provider, verifies `/health`, and POSTs every operation fixture. Set APIFUSE__PROVIDER__* env vars when live upstream credentials are available.",
|
|
2080
|
+
evidence: deprecatedEvidence,
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
const evidence = [
|
|
2085
|
+
`/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
|
|
2086
|
+
...smokeResult.operations.map(
|
|
2087
|
+
(outcome) =>
|
|
2088
|
+
`${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
|
|
2089
|
+
),
|
|
2090
|
+
...deprecatedEvidence,
|
|
2091
|
+
];
|
|
2092
|
+
const incoherent = smokeResult.operations.filter((outcome) => outcome.status === "incoherent");
|
|
2093
|
+
if (!smokeResult.healthOk || smokeResult.bootError || incoherent.length > 0) {
|
|
2094
|
+
return {
|
|
2095
|
+
id: "local-smoke",
|
|
2096
|
+
category: "smoke",
|
|
2097
|
+
level: "blocker",
|
|
2098
|
+
status: "fail",
|
|
2099
|
+
points: 0,
|
|
2100
|
+
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
2101
|
+
message: "Measured smoke failed to verify a coherent provider runtime.",
|
|
2102
|
+
remediation:
|
|
2103
|
+
"Fix the dev server boot, `/health`, or incoherent operation responses, then rerun `bun run submit-check -- --smoke`.",
|
|
2104
|
+
evidence: smokeResult.bootError ? [`boot: ${smokeResult.bootError}`, ...evidence] : evidence,
|
|
2105
|
+
details: smokeResult,
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
|
|
2110
|
+
if (successes.length > 0) {
|
|
2189
2111
|
return {
|
|
2190
2112
|
id: "local-smoke",
|
|
2191
2113
|
category: "smoke",
|
|
@@ -2193,8 +2115,9 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
|
|
|
2193
2115
|
status: "pass",
|
|
2194
2116
|
points: CATEGORY_MAX_POINTS.smoke,
|
|
2195
2117
|
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
2196
|
-
message: "
|
|
2197
|
-
evidence
|
|
2118
|
+
message: "Measured smoke passed with at least one schema-valid operation success.",
|
|
2119
|
+
evidence,
|
|
2120
|
+
details: smokeResult,
|
|
2198
2121
|
};
|
|
2199
2122
|
}
|
|
2200
2123
|
|
|
@@ -2203,14 +2126,226 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
|
|
|
2203
2126
|
category: "smoke",
|
|
2204
2127
|
level: "warn",
|
|
2205
2128
|
status: "warn",
|
|
2206
|
-
points:
|
|
2129
|
+
points: 7,
|
|
2207
2130
|
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
2208
|
-
message: "
|
|
2131
|
+
message: "Runtime path was verified, but no live upstream schema-valid success was observed.",
|
|
2209
2132
|
remediation:
|
|
2210
|
-
"
|
|
2133
|
+
"Provide APIFUSE__PROVIDER__* env vars or fixture-safe upstream access, then rerun `bun run submit-check -- --smoke` to capture at least one schema-valid success.",
|
|
2134
|
+
evidence,
|
|
2135
|
+
details: smokeResult,
|
|
2211
2136
|
};
|
|
2212
2137
|
}
|
|
2213
2138
|
|
|
2139
|
+
export async function runSubmitCheckSmoke(
|
|
2140
|
+
providerRoot: string,
|
|
2141
|
+
provider?: ProviderDefinition,
|
|
2142
|
+
): Promise<SmokeResult> {
|
|
2143
|
+
const loadedProvider = provider ?? (await loadProvider(providerRoot));
|
|
2144
|
+
if (!loadedProvider) {
|
|
2145
|
+
return {
|
|
2146
|
+
measured: true,
|
|
2147
|
+
healthOk: false,
|
|
2148
|
+
bootError: "Provider could not be loaded.",
|
|
2149
|
+
operations: [],
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
const port = await getAvailablePort();
|
|
2154
|
+
const server = spawn("bun", ["run", "dev"], {
|
|
2155
|
+
cwd: providerRoot,
|
|
2156
|
+
env: { ...process.env, APIFUSE__RUNTIME__PORT: String(port) },
|
|
2157
|
+
detached: process.platform !== "win32",
|
|
2158
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2159
|
+
});
|
|
2160
|
+
let output = "";
|
|
2161
|
+
server.stdout?.on("data", (chunk) => {
|
|
2162
|
+
output += chunk.toString();
|
|
2163
|
+
});
|
|
2164
|
+
server.stderr?.on("data", (chunk) => {
|
|
2165
|
+
output += chunk.toString();
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
try {
|
|
2169
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
2170
|
+
const health = await waitForSmokeHealth(`${baseUrl}/health`, server, () => output);
|
|
2171
|
+
if (!health.ok) {
|
|
2172
|
+
return {
|
|
2173
|
+
measured: true,
|
|
2174
|
+
healthOk: false,
|
|
2175
|
+
bootError: health.error,
|
|
2176
|
+
operations: [],
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
const operations: SmokeOperationOutcome[] = [];
|
|
2180
|
+
for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
|
|
2181
|
+
operations.push(
|
|
2182
|
+
await smokeOperation(baseUrl, operationId, operation.output, {
|
|
2183
|
+
requestId: `req_submit_check_smoke_${operationId}`,
|
|
2184
|
+
input: operation.fixtures?.request ?? {},
|
|
2185
|
+
headers: {},
|
|
2186
|
+
}),
|
|
2187
|
+
);
|
|
2188
|
+
}
|
|
2189
|
+
return { measured: true, healthOk: true, operations };
|
|
2190
|
+
} finally {
|
|
2191
|
+
await stopSmokeServer(server);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
async function smokeOperation(
|
|
2196
|
+
baseUrl: string,
|
|
2197
|
+
operationId: string,
|
|
2198
|
+
outputSchema: ProviderDefinition["operations"][string]["output"],
|
|
2199
|
+
body: unknown,
|
|
2200
|
+
): Promise<SmokeOperationOutcome> {
|
|
2201
|
+
try {
|
|
2202
|
+
const response = await fetch(`${baseUrl}/v1/${operationId}`, {
|
|
2203
|
+
method: "POST",
|
|
2204
|
+
headers: { "content-type": "application/json" },
|
|
2205
|
+
body: JSON.stringify(body),
|
|
2206
|
+
signal: AbortSignal.timeout(20_000),
|
|
2207
|
+
});
|
|
2208
|
+
const payload = await response.json().catch(() => undefined);
|
|
2209
|
+
if (response.ok && isRecord(payload) && "data" in payload) {
|
|
2210
|
+
const parsed = safeParseSchemaSync(
|
|
2211
|
+
outputSchema,
|
|
2212
|
+
payload.data,
|
|
2213
|
+
`operations.${operationId}.output`,
|
|
2214
|
+
);
|
|
2215
|
+
if (parsed.success) {
|
|
2216
|
+
return {
|
|
2217
|
+
operationId,
|
|
2218
|
+
status: "success",
|
|
2219
|
+
httpStatus: response.status,
|
|
2220
|
+
message: "schema-valid success",
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
return {
|
|
2224
|
+
operationId,
|
|
2225
|
+
status: "incoherent",
|
|
2226
|
+
httpStatus: response.status,
|
|
2227
|
+
message: "success payload failed output schema validation",
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
if (isStructuredProviderError(payload) && response.status < 500) {
|
|
2231
|
+
return {
|
|
2232
|
+
operationId,
|
|
2233
|
+
status: "structured_error",
|
|
2234
|
+
httpStatus: response.status,
|
|
2235
|
+
message: `${payload.error.code}: ${payload.error.message}`,
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
return {
|
|
2239
|
+
operationId,
|
|
2240
|
+
status: "incoherent",
|
|
2241
|
+
httpStatus: response.status,
|
|
2242
|
+
message: isStructuredProviderError(payload)
|
|
2243
|
+
? `${payload.error.code}: ${payload.error.message}`
|
|
2244
|
+
: "response was not a schema-valid success or structured provider error",
|
|
2245
|
+
};
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
return {
|
|
2248
|
+
operationId,
|
|
2249
|
+
status: "incoherent",
|
|
2250
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
function isStructuredProviderError(
|
|
2256
|
+
value: unknown,
|
|
2257
|
+
): value is { error: { code: string; message: string } } {
|
|
2258
|
+
return (
|
|
2259
|
+
isRecord(value) &&
|
|
2260
|
+
isRecord(value.error) &&
|
|
2261
|
+
typeof value.error.code === "string" &&
|
|
2262
|
+
typeof value.error.message === "string"
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
async function getAvailablePort(): Promise<number> {
|
|
2267
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
2268
|
+
const server = createServer();
|
|
2269
|
+
server.once("error", rejectPromise);
|
|
2270
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2271
|
+
const address = server.address();
|
|
2272
|
+
server.close((error) => {
|
|
2273
|
+
if (error) {
|
|
2274
|
+
rejectPromise(error);
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
if (!address || typeof address === "string") {
|
|
2278
|
+
rejectPromise(new Error("Could not allocate a local TCP port."));
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
resolvePromise(address.port);
|
|
2282
|
+
});
|
|
2283
|
+
});
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
async function waitForSmokeHealth(
|
|
2288
|
+
url: string,
|
|
2289
|
+
server: ChildProcess,
|
|
2290
|
+
getOutput: () => string,
|
|
2291
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
2292
|
+
const deadline = Date.now() + 20_000;
|
|
2293
|
+
let lastError: unknown;
|
|
2294
|
+
|
|
2295
|
+
while (Date.now() < deadline) {
|
|
2296
|
+
if (server.exitCode !== null) {
|
|
2297
|
+
return {
|
|
2298
|
+
ok: false,
|
|
2299
|
+
error: `Dev server exited early with code ${server.exitCode}. ${getOutput()}`,
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
try {
|
|
2304
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
|
|
2305
|
+
if (response.ok) return { ok: true };
|
|
2306
|
+
lastError = new Error(`${url} returned ${response.status}`);
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
lastError = error;
|
|
2309
|
+
}
|
|
2310
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
return {
|
|
2314
|
+
ok: false,
|
|
2315
|
+
error: `Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}. ${getOutput()}`,
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
async function stopSmokeServer(server: ChildProcess): Promise<void> {
|
|
2320
|
+
if (server.exitCode !== null) return;
|
|
2321
|
+
killSmokeProcessTree(server, "SIGTERM");
|
|
2322
|
+
await new Promise<void>((resolvePromise) => {
|
|
2323
|
+
const timeout = setTimeout(() => {
|
|
2324
|
+
if (server.exitCode === null) {
|
|
2325
|
+
killSmokeProcessTree(server, "SIGKILL");
|
|
2326
|
+
}
|
|
2327
|
+
resolvePromise();
|
|
2328
|
+
}, 2_000);
|
|
2329
|
+
server.once("exit", () => {
|
|
2330
|
+
clearTimeout(timeout);
|
|
2331
|
+
resolvePromise();
|
|
2332
|
+
});
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
function killSmokeProcessTree(server: ChildProcess, signal: NodeJS.Signals): void {
|
|
2337
|
+
if (server.pid === undefined) return;
|
|
2338
|
+
try {
|
|
2339
|
+
if (process.platform === "win32") {
|
|
2340
|
+
server.kill(signal);
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
process.kill(-server.pid, signal);
|
|
2344
|
+
} catch {
|
|
2345
|
+
server.kill(signal);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2214
2349
|
function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
2215
2350
|
const authMode = provider.auth?.mode ?? "none";
|
|
2216
2351
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
@@ -2250,10 +2385,8 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
|
2250
2385
|
status: "warn",
|
|
2251
2386
|
points: 7,
|
|
2252
2387
|
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
2253
|
-
message:
|
|
2254
|
-
|
|
2255
|
-
remediation:
|
|
2256
|
-
`Either set \`auth.mode\` to the upstream auth model, or mark these public no-auth operations with \`annotations.openWorld: true\`: ${securedOperations.map(([operationId]) => operationId).join(", ")}.`,
|
|
2388
|
+
message: "Provider is no-auth but at least one operation is not marked openWorld.",
|
|
2389
|
+
remediation: `Either set \`auth.mode\` to the upstream auth model, or mark these public no-auth operations with \`annotations.openWorld: true\`: ${securedOperations.map(([operationId]) => operationId).join(", ")}.`,
|
|
2257
2390
|
evidence: securedOperations.map(([operationId]) => operationId),
|
|
2258
2391
|
};
|
|
2259
2392
|
}
|
|
@@ -2295,9 +2428,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
2295
2428
|
|
|
2296
2429
|
const points = Math.max(
|
|
2297
2430
|
0,
|
|
2298
|
-
CATEGORY_MAX_POINTS.docs -
|
|
2299
|
-
missing.length * 2 -
|
|
2300
|
-
(mentionsSubmitCheck ? 0 : 1),
|
|
2431
|
+
CATEGORY_MAX_POINTS.docs - missing.length * 2 - (mentionsSubmitCheck ? 0 : 1),
|
|
2301
2432
|
);
|
|
2302
2433
|
|
|
2303
2434
|
return [
|
|
@@ -2324,9 +2455,10 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
2324
2455
|
];
|
|
2325
2456
|
}
|
|
2326
2457
|
|
|
2327
|
-
function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
2328
|
-
const findings = findSecretFindings(providerRoot);
|
|
2329
|
-
|
|
2458
|
+
function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
|
|
2459
|
+
const findings = findSecretFindings(providerRoot, provider?.id);
|
|
2460
|
+
const blockerFindings = findings.filter((finding) => finding.level !== "warn");
|
|
2461
|
+
if (blockerFindings.length > 0) {
|
|
2330
2462
|
return {
|
|
2331
2463
|
id: "secret-scan",
|
|
2332
2464
|
category: "security",
|
|
@@ -2334,11 +2466,34 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
|
2334
2466
|
status: "fail",
|
|
2335
2467
|
points: 0,
|
|
2336
2468
|
maxPoints: CATEGORY_MAX_POINTS.security,
|
|
2469
|
+
message: "Potential real credential material was found in shareable files.",
|
|
2470
|
+
remediation:
|
|
2471
|
+
blockerFindings[0]?.remediation ??
|
|
2472
|
+
'Move hardcoded credentials to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential.',
|
|
2473
|
+
evidence: blockerFindings.map(
|
|
2474
|
+
(finding) =>
|
|
2475
|
+
finding.evidence ??
|
|
2476
|
+
`${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
|
|
2477
|
+
),
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
if (findings.length > 0) {
|
|
2481
|
+
return {
|
|
2482
|
+
id: "secret-scan",
|
|
2483
|
+
category: "security",
|
|
2484
|
+
level: "warn",
|
|
2485
|
+
status: "warn",
|
|
2486
|
+
points: 8,
|
|
2487
|
+
maxPoints: CATEGORY_MAX_POINTS.security,
|
|
2337
2488
|
message:
|
|
2338
|
-
"
|
|
2489
|
+
"High-entropy source strings were found without secret-like identifier context; they may be false positives.",
|
|
2339
2490
|
remediation:
|
|
2340
|
-
|
|
2341
|
-
evidence: findings.map(
|
|
2491
|
+
'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public.',
|
|
2492
|
+
evidence: findings.map(
|
|
2493
|
+
(finding) =>
|
|
2494
|
+
finding.evidence ??
|
|
2495
|
+
`${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
|
|
2496
|
+
),
|
|
2342
2497
|
};
|
|
2343
2498
|
}
|
|
2344
2499
|
|
|
@@ -2350,7 +2505,7 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
|
2350
2505
|
);
|
|
2351
2506
|
}
|
|
2352
2507
|
|
|
2353
|
-
function findSecretFindings(providerRoot: string): SecretFinding[] {
|
|
2508
|
+
function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFinding[] {
|
|
2354
2509
|
const candidateFiles = [
|
|
2355
2510
|
"README.md",
|
|
2356
2511
|
"index.ts",
|
|
@@ -2371,14 +2526,155 @@ function findSecretFindings(providerRoot: string): SecretFinding[] {
|
|
|
2371
2526
|
}
|
|
2372
2527
|
}
|
|
2373
2528
|
|
|
2529
|
+
findings.push(...findEntropySecretFindings(providerRoot, providerId));
|
|
2374
2530
|
return findings;
|
|
2375
2531
|
}
|
|
2376
2532
|
|
|
2533
|
+
function findEntropySecretFindings(providerRoot: string, providerId: string): SecretFinding[] {
|
|
2534
|
+
const findings: SecretFinding[] = [];
|
|
2535
|
+
for (const filePath of listNonTestProviderSourceFiles(providerRoot)) {
|
|
2536
|
+
const relativePath = toRelativeProviderPath(providerRoot, filePath);
|
|
2537
|
+
if (isEntropySecretExcludedPath(relativePath)) continue;
|
|
2538
|
+
const content = readFileSync(filePath, "utf8");
|
|
2539
|
+
const lines = content.split(/\r?\n/);
|
|
2540
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
2541
|
+
const line = lines[lineIndex] ?? "";
|
|
2542
|
+
for (const candidate of extractStringLiteralCandidates(line)) {
|
|
2543
|
+
const finding = classifyEntropyCandidate({
|
|
2544
|
+
value: candidate,
|
|
2545
|
+
line,
|
|
2546
|
+
file: relativePath,
|
|
2547
|
+
lineNumber: lineIndex + 1,
|
|
2548
|
+
providerId,
|
|
2549
|
+
});
|
|
2550
|
+
if (finding) findings.push(finding);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
return findings;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
function isEntropySecretExcludedPath(relativePath: string): boolean {
|
|
2558
|
+
return (
|
|
2559
|
+
relativePath.endsWith(".test.ts") ||
|
|
2560
|
+
relativePath.startsWith("__tests__/") ||
|
|
2561
|
+
relativePath.includes("/__tests__/") ||
|
|
2562
|
+
relativePath.startsWith("__fixtures__/") ||
|
|
2563
|
+
relativePath.includes("/__fixtures__/")
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
export function extractStringLiteralCandidates(line: string): string[] {
|
|
2568
|
+
const candidates: string[] = [];
|
|
2569
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
2570
|
+
const quote = line[index];
|
|
2571
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") continue;
|
|
2572
|
+
|
|
2573
|
+
const contentStart = index + 1;
|
|
2574
|
+
let cursor = contentStart;
|
|
2575
|
+
while (cursor < line.length) {
|
|
2576
|
+
const char = line[cursor];
|
|
2577
|
+
if (char === "\\") {
|
|
2578
|
+
cursor += 2;
|
|
2579
|
+
continue;
|
|
2580
|
+
}
|
|
2581
|
+
if (char === quote) {
|
|
2582
|
+
if (cursor - contentStart >= 20) {
|
|
2583
|
+
candidates.push(line.slice(contentStart, cursor));
|
|
2584
|
+
}
|
|
2585
|
+
index = cursor;
|
|
2586
|
+
break;
|
|
2587
|
+
}
|
|
2588
|
+
cursor += 1;
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
return candidates;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
function classifyEntropyCandidate(input: {
|
|
2595
|
+
value: string;
|
|
2596
|
+
line: string;
|
|
2597
|
+
file: string;
|
|
2598
|
+
lineNumber: number;
|
|
2599
|
+
providerId: string;
|
|
2600
|
+
}): SecretFinding | undefined {
|
|
2601
|
+
const value = input.value;
|
|
2602
|
+
if (!shouldConsiderEntropyValue(value)) return undefined;
|
|
2603
|
+
const charset = classifyEntropyCharset(value);
|
|
2604
|
+
if (!charset) return undefined;
|
|
2605
|
+
const entropy = shannonEntropy(value);
|
|
2606
|
+
const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
|
|
2607
|
+
const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
|
|
2608
|
+
if (entropy < threshold) return undefined;
|
|
2609
|
+
|
|
2610
|
+
const preview = `${value.slice(0, 4)}...[REDACTED length=${value.length}]`;
|
|
2611
|
+
const envName = `APIFUSE__PROVIDER__${input.providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}__${guessSecretName(input.line)}`;
|
|
2612
|
+
const location = `${input.file}:${input.lineNumber}`;
|
|
2613
|
+
const label =
|
|
2614
|
+
charset === "hex"
|
|
2615
|
+
? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
|
|
2616
|
+
: `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
|
|
2617
|
+
return {
|
|
2618
|
+
label,
|
|
2619
|
+
file: input.file,
|
|
2620
|
+
line: input.lineNumber,
|
|
2621
|
+
level: secretishContext ? "blocker" : "warn",
|
|
2622
|
+
remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
|
|
2623
|
+
evidence: `${location}: ${label}; preview ${preview}${secretishContext ? "" : "; may be a false positive"}`,
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function shouldConsiderEntropyValue(value: string): boolean {
|
|
2628
|
+
const lower = value.toLowerCase();
|
|
2629
|
+
if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
|
|
2630
|
+
return false;
|
|
2631
|
+
}
|
|
2632
|
+
if (/^sha(?:256|512)-/i.test(value)) return false;
|
|
2633
|
+
if (/\s/.test(value)) return false;
|
|
2634
|
+
if (value.includes("${")) return false;
|
|
2635
|
+
if (value.includes("/")) return false;
|
|
2636
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
|
|
2637
|
+
if (/^(?:\.{0,2}\/|~\/|[A-Za-z]:\\)/.test(value)) return false;
|
|
2638
|
+
if (value.includes(".") && /^[A-Za-z0-9_.-]+$/.test(value)) return false;
|
|
2639
|
+
if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
|
|
2640
|
+
return false;
|
|
2641
|
+
}
|
|
2642
|
+
return value.length >= 20;
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
|
|
2646
|
+
if (/^[a-f0-9]+$/i.test(value) && value.length >= 32) return "hex";
|
|
2647
|
+
const base64ishChars = value.match(/[A-Za-z0-9+/=_-]/g)?.length ?? 0;
|
|
2648
|
+
if (base64ishChars / value.length >= 0.9) return "base64";
|
|
2649
|
+
return undefined;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
function shannonEntropy(value: string): number {
|
|
2653
|
+
const counts = new Map<string, number>();
|
|
2654
|
+
for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
2655
|
+
let entropy = 0;
|
|
2656
|
+
for (const count of counts.values()) {
|
|
2657
|
+
const probability = count / value.length;
|
|
2658
|
+
entropy -= probability * Math.log2(probability);
|
|
2659
|
+
}
|
|
2660
|
+
return entropy;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function guessSecretName(line: string): string {
|
|
2664
|
+
const match =
|
|
2665
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
|
|
2666
|
+
/["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
|
|
2667
|
+
const raw = match?.[1] ?? "SECRET";
|
|
2668
|
+
return raw
|
|
2669
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
2670
|
+
.toUpperCase()
|
|
2671
|
+
.replace(/[^A-Z0-9]+/g, "_");
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
|
|
2675
|
+
|
|
2377
2676
|
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
2378
|
-
[
|
|
2379
|
-
"JWT-like token",
|
|
2380
|
-
/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/,
|
|
2381
|
-
],
|
|
2677
|
+
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
2382
2678
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
|
2383
2679
|
["Stripe live key", /(?:sk|rk)_live_[A-Za-z0-9]{20,}/],
|
|
2384
2680
|
["Bearer token", /Bearer\s+[A-Za-z0-9._~+/=-]{32,}/i],
|
|
@@ -2388,9 +2684,7 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
|
2388
2684
|
],
|
|
2389
2685
|
];
|
|
2390
2686
|
|
|
2391
|
-
async function safeLoadProvider(
|
|
2392
|
-
providerRoot: string,
|
|
2393
|
-
): Promise<ProviderDefinition | undefined> {
|
|
2687
|
+
async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|
|
2394
2688
|
try {
|
|
2395
2689
|
return await loadProvider(providerRoot);
|
|
2396
2690
|
} catch {
|
|
@@ -2398,9 +2692,7 @@ async function safeLoadProvider(
|
|
|
2398
2692
|
}
|
|
2399
2693
|
}
|
|
2400
2694
|
|
|
2401
|
-
async function loadProvider(
|
|
2402
|
-
providerRoot: string,
|
|
2403
|
-
): Promise<ProviderDefinition | undefined> {
|
|
2695
|
+
async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|
|
2404
2696
|
const entryPath = resolve(providerRoot, "index.ts");
|
|
2405
2697
|
if (!existsSync(entryPath)) {
|
|
2406
2698
|
return undefined;
|
|
@@ -2480,8 +2772,7 @@ export function renderText(report: SubmitCheckReport): string {
|
|
|
2480
2772
|
];
|
|
2481
2773
|
|
|
2482
2774
|
for (const check of report.checks) {
|
|
2483
|
-
const marker =
|
|
2484
|
-
check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
|
|
2775
|
+
const marker = check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
|
|
2485
2776
|
lines.push(
|
|
2486
2777
|
`${marker} [${check.category}] ${check.message} (${check.points}/${check.maxPoints})`,
|
|
2487
2778
|
);
|
|
@@ -2503,9 +2794,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
|
|
|
2503
2794
|
`- **Provider**: ${report.provider.id}@${report.provider.version}`,
|
|
2504
2795
|
`- **SDK**: ${report.provider.sdkVersion}`,
|
|
2505
2796
|
`- **Runtime/Auth**: ${report.provider.runtime} / ${report.provider.authMode}`,
|
|
2506
|
-
...(report.provider.tier
|
|
2507
|
-
? [`- **Bounty tier**: ${report.provider.tier}`]
|
|
2508
|
-
: []),
|
|
2797
|
+
...(report.provider.tier ? [`- **Bounty tier**: ${report.provider.tier}`] : []),
|
|
2509
2798
|
`- **Score**: ${report.score.total}/${report.score.max}`,
|
|
2510
2799
|
`- **Verdict**: ${report.score.verdict}`,
|
|
2511
2800
|
`- **Blockers**: ${report.summary.blockers}`,
|
|
@@ -2518,12 +2807,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
|
|
|
2518
2807
|
];
|
|
2519
2808
|
|
|
2520
2809
|
for (const check of report.checks) {
|
|
2521
|
-
const status =
|
|
2522
|
-
check.status === "pass"
|
|
2523
|
-
? "PASS"
|
|
2524
|
-
: check.status === "warn"
|
|
2525
|
-
? "WARN"
|
|
2526
|
-
: "FAIL";
|
|
2810
|
+
const status = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
|
|
2527
2811
|
lines.push(
|
|
2528
2812
|
`| ${status} | ${escapeMarkdown(check.category)} | ${escapeMarkdown(check.message)} | ${check.points}/${check.maxPoints} | ${escapeMarkdown(check.remediation ?? "")} |`,
|
|
2529
2813
|
);
|
|
@@ -2553,9 +2837,7 @@ function redact(value: string): string {
|
|
|
2553
2837
|
}
|
|
2554
2838
|
|
|
2555
2839
|
function toGlobalRegex(pattern: RegExp): RegExp {
|
|
2556
|
-
return pattern.global
|
|
2557
|
-
? pattern
|
|
2558
|
-
: new RegExp(pattern.source, `${pattern.flags}g`);
|
|
2840
|
+
return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
|
|
2559
2841
|
}
|
|
2560
2842
|
|
|
2561
2843
|
function clamp(value: number, min: number, max: number): number {
|