@apifuse/provider-sdk 2.2.0-beta.3 → 2.2.0-beta.5
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/AUTHORING.md +53 -0
- package/CHANGELOG.md +8 -0
- package/bin/apifuse-check.ts +18 -58
- package/bin/apifuse-create.ts +1 -1
- package/bin/apifuse-dev.ts +27 -52
- package/bin/apifuse-pack-smoke.ts +22 -81
- package/bin/apifuse-pack-types.ts +266 -0
- package/bin/apifuse-perf.ts +45 -127
- package/bin/apifuse-record.ts +23 -61
- package/bin/apifuse-submit-check.ts +266 -34
- package/bin/apifuse.ts +1 -1
- package/bin/submit-check-xml.ts +1 -1
- package/dist/auth-turn/index.d.ts +1 -1
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +9 -18
- package/dist/ceremonies/index.d.ts +1 -1
- package/dist/ceremonies/index.js +8 -18
- package/dist/config/loader.d.ts +2 -2
- package/dist/config/loader.js +19 -42
- package/dist/contract-serialization.d.ts +2 -2
- package/dist/contract-serialization.js +3 -6
- package/dist/contract-types.d.ts +2 -2
- package/dist/contract.d.ts +3 -3
- package/dist/contract.js +4 -6
- package/dist/define.d.ts +8 -1
- package/dist/define.js +57 -115
- package/dist/dev.d.ts +1 -1
- package/dist/dev.js +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -3
- package/dist/i18n/catalog.d.ts +2 -2
- package/dist/i18n/catalog.js +4 -10
- package/dist/i18n/index.d.ts +2 -2
- package/dist/i18n/index.js +2 -2
- package/dist/i18n/keys.d.ts +2 -2
- package/dist/index.d.ts +43 -42
- package/dist/index.js +38 -37
- package/dist/lint.js +8 -15
- package/dist/provider.d.ts +11 -11
- package/dist/provider.js +9 -9
- package/dist/public-schema-field-lint.d.ts +1 -1
- package/dist/recipes/gov-api.js +1 -1
- package/dist/runtime/auth-flow.d.ts +1 -1
- package/dist/runtime/auth-flow.js +3 -3
- package/dist/runtime/browser.d.ts +1 -1
- package/dist/runtime/browser.js +15 -29
- package/dist/runtime/cache.d.ts +1 -1
- package/dist/runtime/cache.js +4 -8
- package/dist/runtime/choice.d.ts +1 -1
- package/dist/runtime/choice.js +12 -34
- package/dist/runtime/credential.d.ts +1 -1
- package/dist/runtime/credential.js +1 -1
- package/dist/runtime/env.d.ts +1 -1
- package/dist/runtime/executor.d.ts +1 -1
- package/dist/runtime/executor.js +2 -2
- package/dist/runtime/http.d.ts +2 -2
- package/dist/runtime/http.js +5 -5
- package/dist/runtime/insights.d.ts +1 -1
- package/dist/runtime/insights.js +6 -13
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +5 -15
- package/dist/runtime/keyring.js +1 -1
- package/dist/runtime/namespace.js +1 -1
- package/dist/runtime/otlp.d.ts +1 -1
- package/dist/runtime/perf.d.ts +1 -1
- package/dist/runtime/provider.d.ts +1 -1
- package/dist/runtime/provider.js +1 -2
- package/dist/runtime/proxy-errors.d.ts +1 -1
- package/dist/runtime/proxy-errors.js +3 -5
- package/dist/runtime/proxy-retry-policy.d.ts +2 -2
- package/dist/runtime/proxy-retry-policy.js +2 -2
- package/dist/runtime/proxy-telemetry.d.ts +1 -1
- package/dist/runtime/proxy-telemetry.js +16 -48
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +2 -4
- package/dist/runtime/request-options.d.ts +1 -1
- package/dist/runtime/state.d.ts +2 -2
- package/dist/runtime/state.js +3 -3
- package/dist/runtime/stealth.d.ts +2 -2
- package/dist/runtime/stealth.js +6 -6
- package/dist/runtime/stt.d.ts +1 -1
- package/dist/runtime/stt.js +11 -15
- package/dist/runtime/trace.d.ts +2 -2
- package/dist/runtime/trace.js +2 -4
- package/dist/runtime/waterfall.d.ts +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +7 -15
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +6 -6
- package/dist/server/self-test-redaction.d.ts +1 -1
- package/dist/server/self-test-redaction.js +1 -1
- package/dist/server/self-test.d.ts +104 -3
- package/dist/server/self-test.js +673 -115
- package/dist/server/serve.d.ts +3 -3
- package/dist/server/serve.js +37 -49
- package/dist/server/types.js +2 -7
- package/dist/stealth/profiles.d.ts +1 -1
- package/dist/stealth/profiles.js +5 -14
- package/dist/stream.d.ts +1 -1
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +2 -2
- package/dist/testing/run.d.ts +1 -1
- package/dist/testing/run.js +7 -14
- package/dist/types.d.ts +57 -0
- package/dist/user-input.d.ts +30 -0
- package/dist/user-input.js +66 -0
- package/package.json +6 -4
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +28 -86
- package/src/ceremonies/index.ts +24 -78
- package/src/config/loader.ts +58 -176
- package/src/contract-serialization.ts +5 -11
- package/src/contract-types.ts +2 -2
- package/src/contract.ts +12 -28
- package/src/define.ts +174 -495
- package/src/dev.ts +4 -9
- package/src/errors.ts +4 -11
- package/src/i18n/catalog.ts +10 -32
- package/src/i18n/index.ts +2 -2
- package/src/i18n/keys.ts +5 -11
- package/src/index.ts +44 -42
- package/src/lint.ts +89 -159
- package/src/provider.ts +12 -11
- package/src/public-schema-field-lint.ts +7 -33
- package/src/recipes/gov-api.ts +2 -5
- package/src/runtime/auth-flow.ts +5 -7
- package/src/runtime/browser.ts +64 -187
- package/src/runtime/cache.ts +20 -67
- package/src/runtime/choice.ts +55 -129
- package/src/runtime/credential.ts +2 -2
- package/src/runtime/env.ts +1 -1
- package/src/runtime/executor.ts +9 -19
- package/src/runtime/http.ts +7 -7
- package/src/runtime/insights.ts +15 -53
- package/src/runtime/instrumentation.ts +19 -58
- package/src/runtime/keyring.ts +7 -19
- package/src/runtime/namespace.ts +2 -7
- package/src/runtime/otlp.ts +12 -23
- package/src/runtime/perf.ts +1 -1
- package/src/runtime/provider.ts +4 -9
- package/src/runtime/proxy-errors.ts +17 -38
- package/src/runtime/proxy-retry-policy.ts +3 -3
- package/src/runtime/proxy-telemetry.ts +23 -72
- package/src/runtime/redis.ts +4 -12
- package/src/runtime/request-options.ts +4 -13
- package/src/runtime/state.ts +30 -109
- package/src/runtime/stealth.ts +8 -8
- package/src/runtime/stt.ts +38 -94
- package/src/runtime/trace.ts +14 -44
- package/src/runtime/waterfall.ts +5 -18
- package/src/schema.ts +23 -84
- package/src/serve.ts +1 -1
- package/src/server/index.ts +12 -7
- package/src/server/self-test-redaction.ts +2 -2
- package/src/server/self-test.ts +857 -132
- package/src/server/serve.ts +89 -238
- package/src/server/types.ts +6 -19
- package/src/stealth/profiles.ts +10 -26
- package/src/stream.ts +8 -19
- package/src/testing/index.ts +2 -2
- package/src/testing/run.ts +19 -67
- package/src/types.ts +58 -0
- package/src/user-input.ts +118 -0
package/bin/apifuse-record.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
type StealthClient,
|
|
21
21
|
TransportError,
|
|
22
22
|
ValidationError,
|
|
23
|
-
} from "../src";
|
|
24
|
-
import { createMemoryProviderRuntimeState } from "../src/runtime/state";
|
|
23
|
+
} from "../src/index.js";
|
|
24
|
+
import { createMemoryProviderRuntimeState } from "../src/runtime/state.js";
|
|
25
25
|
|
|
26
26
|
type CliArgs = {
|
|
27
27
|
append: boolean;
|
|
@@ -64,31 +64,18 @@ export async function main() {
|
|
|
64
64
|
resolveOperationBaseUrl(provider, operationName),
|
|
65
65
|
);
|
|
66
66
|
|
|
67
|
-
console.log(
|
|
68
|
-
`[apifuse record] Calling ${operationName} on ${provider.id}...`,
|
|
69
|
-
);
|
|
67
|
+
console.log(`[apifuse record] Calling ${operationName} on ${provider.id}...`);
|
|
70
68
|
|
|
71
|
-
const result = await executeOperation(
|
|
72
|
-
provider,
|
|
73
|
-
operationName,
|
|
74
|
-
capture.ctx,
|
|
75
|
-
parsedParams,
|
|
76
|
-
);
|
|
69
|
+
const result = await executeOperation(provider, operationName, capture.ctx, parsedParams);
|
|
77
70
|
const captured = capture.getCapturedRaw();
|
|
78
71
|
|
|
79
72
|
if (captured === undefined) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
`No upstream response was captured for ${provider.id}.${operationName}.`,
|
|
82
|
-
);
|
|
73
|
+
throw new Error(`No upstream response was captured for ${provider.id}.${operationName}.`);
|
|
83
74
|
}
|
|
84
75
|
|
|
85
76
|
const rawPayload = args.sanitize ? sanitizeFixture(captured) : captured;
|
|
86
77
|
const fixturePath = resolve(location.rootDir, "__fixtures__", "raw.json");
|
|
87
|
-
const nextPayload = await prepareFixturePayload(
|
|
88
|
-
fixturePath,
|
|
89
|
-
rawPayload,
|
|
90
|
-
args.append,
|
|
91
|
-
);
|
|
78
|
+
const nextPayload = await prepareFixturePayload(fixturePath, rawPayload, args.append);
|
|
92
79
|
|
|
93
80
|
await mkdir(dirname(fixturePath), { recursive: true });
|
|
94
81
|
await writeFile(fixturePath, `${JSON.stringify(nextPayload, null, 2)}\n`);
|
|
@@ -98,9 +85,7 @@ export async function main() {
|
|
|
98
85
|
Buffer.byteLength(JSON.stringify(rawPayload)),
|
|
99
86
|
)})`,
|
|
100
87
|
);
|
|
101
|
-
console.log(
|
|
102
|
-
`[apifuse record] Saved to ${relative(process.cwd(), fixturePath)}`,
|
|
103
|
-
);
|
|
88
|
+
console.log(`[apifuse record] Saved to ${relative(process.cwd(), fixturePath)}`);
|
|
104
89
|
|
|
105
90
|
void result;
|
|
106
91
|
} catch (error) {
|
|
@@ -210,11 +195,7 @@ function formatCliError(error: unknown): string {
|
|
|
210
195
|
}
|
|
211
196
|
|
|
212
197
|
if (error instanceof ProviderError || error instanceof ValidationError) {
|
|
213
|
-
return [
|
|
214
|
-
error.message,
|
|
215
|
-
error.code ? `code=${error.code}` : undefined,
|
|
216
|
-
error.fix,
|
|
217
|
-
]
|
|
198
|
+
return [error.message, error.code ? `code=${error.code}` : undefined, error.fix]
|
|
218
199
|
.filter(Boolean)
|
|
219
200
|
.join(" ");
|
|
220
201
|
}
|
|
@@ -269,8 +250,7 @@ function findProviderRoot(startDirectory: string): string | undefined {
|
|
|
269
250
|
|
|
270
251
|
function looksLikeProviderRoot(directory: string): boolean {
|
|
271
252
|
return (
|
|
272
|
-
existsSync(resolve(directory, "index.ts")) &&
|
|
273
|
-
existsSync(resolve(directory, "package.json"))
|
|
253
|
+
existsSync(resolve(directory, "index.ts")) && existsSync(resolve(directory, "package.json"))
|
|
274
254
|
);
|
|
275
255
|
}
|
|
276
256
|
|
|
@@ -287,15 +267,10 @@ async function loadProvider(rootDir: string): Promise<ProviderRuntime> {
|
|
|
287
267
|
return module.default;
|
|
288
268
|
}
|
|
289
269
|
|
|
290
|
-
function resolveOperationName(
|
|
291
|
-
provider: ProviderRuntime,
|
|
292
|
-
operationName?: string,
|
|
293
|
-
): string {
|
|
270
|
+
function resolveOperationName(provider: ProviderRuntime, operationName?: string): string {
|
|
294
271
|
if (operationName) {
|
|
295
272
|
if (!(operationName in provider.operations)) {
|
|
296
|
-
throw new Error(
|
|
297
|
-
`Unknown operation "${operationName}" for provider "${provider.id}".`,
|
|
298
|
-
);
|
|
273
|
+
throw new Error(`Unknown operation "${operationName}" for provider "${provider.id}".`);
|
|
299
274
|
}
|
|
300
275
|
|
|
301
276
|
return operationName;
|
|
@@ -309,10 +284,7 @@ function resolveOperationName(
|
|
|
309
284
|
return firstOperation;
|
|
310
285
|
}
|
|
311
286
|
|
|
312
|
-
function parseParams(
|
|
313
|
-
operation: ProviderRuntime["operations"][string],
|
|
314
|
-
value: string,
|
|
315
|
-
): unknown {
|
|
287
|
+
function parseParams(operation: ProviderRuntime["operations"][string], value: string): unknown {
|
|
316
288
|
let parsed: unknown;
|
|
317
289
|
|
|
318
290
|
try {
|
|
@@ -326,10 +298,7 @@ function parseParams(
|
|
|
326
298
|
return operation.input ? operation.input.parse(parsed) : parsed;
|
|
327
299
|
}
|
|
328
300
|
|
|
329
|
-
function resolveOperationBaseUrl(
|
|
330
|
-
provider: ProviderRuntime,
|
|
331
|
-
operationName: string,
|
|
332
|
-
): string {
|
|
301
|
+
function resolveOperationBaseUrl(provider: ProviderRuntime, operationName: string): string {
|
|
333
302
|
const baseUrl = provider.operations[operationName]?.upstream?.baseUrl;
|
|
334
303
|
if (!baseUrl) {
|
|
335
304
|
throw new Error(
|
|
@@ -346,12 +315,9 @@ function createCaptureContext(provider: ProviderRuntime, baseUrl: string) {
|
|
|
346
315
|
const http = proxyHttpClient(createHttpClient(baseUrl), (response) => {
|
|
347
316
|
capturedRaw = response.data;
|
|
348
317
|
});
|
|
349
|
-
const stealth = proxyStealthClient(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
capturedRaw = normalizeCapturedStealthResponse(response);
|
|
353
|
-
},
|
|
354
|
-
);
|
|
318
|
+
const stealth = proxyStealthClient(createStealthClient(baseUrl), (response) => {
|
|
319
|
+
capturedRaw = normalizeCapturedStealthResponse(response);
|
|
320
|
+
});
|
|
355
321
|
|
|
356
322
|
const env = {
|
|
357
323
|
get: (key: string) => process.env[key],
|
|
@@ -464,9 +430,7 @@ function proxyStealthSession(
|
|
|
464
430
|
};
|
|
465
431
|
}
|
|
466
432
|
|
|
467
|
-
function normalizeCapturedStealthResponse(
|
|
468
|
-
response: Awaited<ReturnType<StealthClient["fetch"]>>,
|
|
469
|
-
) {
|
|
433
|
+
function normalizeCapturedStealthResponse(response: Awaited<ReturnType<StealthClient["fetch"]>>) {
|
|
470
434
|
try {
|
|
471
435
|
return JSON.parse(response.body);
|
|
472
436
|
} catch {
|
|
@@ -483,15 +447,13 @@ function sanitizeFixture(value: unknown): unknown {
|
|
|
483
447
|
return value;
|
|
484
448
|
}
|
|
485
449
|
|
|
486
|
-
const entries = Object.entries(value as MutableRecord).map(
|
|
487
|
-
(
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
450
|
+
const entries = Object.entries(value as MutableRecord).map(([key, entryValue]) => {
|
|
451
|
+
if (isSensitiveKey(key)) {
|
|
452
|
+
return [key, "[REDACTED]"] as const;
|
|
453
|
+
}
|
|
491
454
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
);
|
|
455
|
+
return [key, sanitizeFixture(entryValue)] as const;
|
|
456
|
+
});
|
|
495
457
|
|
|
496
458
|
return Object.fromEntries(entries);
|
|
497
459
|
}
|
|
@@ -11,16 +11,16 @@ import * as acorn from "acorn";
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
|
|
13
13
|
import packageJson from "../package.json";
|
|
14
|
-
import type { ProviderDefinition } from "../src";
|
|
14
|
+
import type { ProviderDefinition } from "../src/index.js";
|
|
15
15
|
import {
|
|
16
16
|
loadProviderLocaleCatalogs,
|
|
17
17
|
type ProviderLocale,
|
|
18
18
|
validateProviderLocaleCatalogs,
|
|
19
|
-
} from "../src/i18n";
|
|
20
|
-
import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
|
|
21
|
-
import { safeParseSchemaSync } from "../src/schema";
|
|
22
|
-
import { type CheckResult, runChecks } from "./apifuse-check";
|
|
23
|
-
import { hasSubstantiveXmlStructure } from "./submit-check-xml";
|
|
19
|
+
} from "../src/i18n/index.js";
|
|
20
|
+
import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema.js";
|
|
21
|
+
import { safeParseSchemaSync } from "../src/schema.js";
|
|
22
|
+
import { type CheckResult, runChecks } from "./apifuse-check.js";
|
|
23
|
+
import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
|
|
24
24
|
|
|
25
25
|
const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
|
|
26
26
|
const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
|
|
@@ -1980,10 +1980,7 @@ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
1980
1980
|
|
|
1981
1981
|
const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
|
|
1982
1982
|
|
|
1983
|
-
function scoreFixtureProvenance(
|
|
1984
|
-
providerRoot: string,
|
|
1985
|
-
provider: ProviderDefinition,
|
|
1986
|
-
): SubmitCheck {
|
|
1983
|
+
function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1987
1984
|
const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
|
|
1988
1985
|
let hasRecordedEvidence = false;
|
|
1989
1986
|
if (existsSync(rawPath)) {
|
|
@@ -2090,12 +2087,17 @@ function allOperationsAreGeneratedLocalScaffold(provider: ProviderDefinition): b
|
|
|
2090
2087
|
}
|
|
2091
2088
|
|
|
2092
2089
|
function scoreVendorKeyLeak(providerRoot: string): SubmitCheck {
|
|
2093
|
-
return escapeHatchResult(
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2090
|
+
return escapeHatchResult(
|
|
2091
|
+
providerRoot,
|
|
2092
|
+
"vendor-key-leak",
|
|
2093
|
+
findVendorKeyLeakFindings(providerRoot),
|
|
2094
|
+
{
|
|
2095
|
+
blockerMessage: "Public schema keys leak raw vendor field names.",
|
|
2096
|
+
remediation:
|
|
2097
|
+
"Normalize public request/response fields to APIFuse-standard lowerCamelCase names (e.g. isOpen24h, latitude); keep raw vendor keys only in upstream-parsing schemas (const upstream... = z.object(...)). Add `// @apifuse-allow vendor-key-leak` only with a comment explaining why the vendor name is genuinely canonical.",
|
|
2098
|
+
passMessage: "No vendor field-name leaks detected in public schemas.",
|
|
2099
|
+
},
|
|
2100
|
+
);
|
|
2099
2101
|
}
|
|
2100
2102
|
|
|
2101
2103
|
function scoreVendorTimestampLeak(providerRoot: string): SubmitCheck {
|
|
@@ -2282,9 +2284,7 @@ function collectTopLevelObjectKeys(
|
|
|
2282
2284
|
if (computedQuote === '"' || computedQuote === "'") {
|
|
2283
2285
|
const literalEnd = findStringEnd(source, literalStart);
|
|
2284
2286
|
const afterLiteral =
|
|
2285
|
-
literalEnd === -1
|
|
2286
|
-
? -1
|
|
2287
|
-
: skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
|
|
2287
|
+
literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
|
|
2288
2288
|
if (literalEnd !== -1 && afterLiteral === computedEnd) {
|
|
2289
2289
|
key = source.slice(literalStart + 1, literalEnd);
|
|
2290
2290
|
}
|
|
@@ -2402,8 +2402,13 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
|
|
|
2402
2402
|
return ranges;
|
|
2403
2403
|
}
|
|
2404
2404
|
|
|
2405
|
-
function findConstValueRangeContaining(
|
|
2406
|
-
|
|
2405
|
+
function findConstValueRangeContaining(
|
|
2406
|
+
source: string,
|
|
2407
|
+
offset: number,
|
|
2408
|
+
): NamedObjectRange | undefined {
|
|
2409
|
+
return findNamedConstValueRanges(source).find(
|
|
2410
|
+
(range) => offset >= range.start && offset <= range.end,
|
|
2411
|
+
);
|
|
2407
2412
|
}
|
|
2408
2413
|
|
|
2409
2414
|
function findStringLiteralsInRange(
|
|
@@ -3541,9 +3546,47 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
3541
3546
|
];
|
|
3542
3547
|
}
|
|
3543
3548
|
|
|
3549
|
+
// Splits secret findings into still-active findings and acknowledged
|
|
3550
|
+
// `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
|
|
3551
|
+
// (same pragma placement: the finding line or the line directly above it).
|
|
3552
|
+
// Every finding source carries a line number (entropy candidates and located
|
|
3553
|
+
// SECRET_PATTERNS matches); a finding that somehow lacks one stays active
|
|
3554
|
+
// defensively.
|
|
3555
|
+
function partitionSecretScanAllowOverrides(
|
|
3556
|
+
providerRoot: string,
|
|
3557
|
+
findings: readonly SecretFinding[],
|
|
3558
|
+
): { active: SecretFinding[]; overridden: SecretFinding[] } {
|
|
3559
|
+
const fileLineCache = new Map<string, string[]>();
|
|
3560
|
+
const active: SecretFinding[] = [];
|
|
3561
|
+
const overridden: SecretFinding[] = [];
|
|
3562
|
+
|
|
3563
|
+
for (const finding of findings) {
|
|
3564
|
+
if (finding.line === undefined) {
|
|
3565
|
+
active.push(finding);
|
|
3566
|
+
continue;
|
|
3567
|
+
}
|
|
3568
|
+
const absolute = resolve(providerRoot, finding.file);
|
|
3569
|
+
let lines = fileLineCache.get(absolute);
|
|
3570
|
+
if (lines === undefined) {
|
|
3571
|
+
lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
|
|
3572
|
+
fileLineCache.set(absolute, lines);
|
|
3573
|
+
}
|
|
3574
|
+
if (hasAllowOverride(lines, finding.line, "secret-scan")) {
|
|
3575
|
+
overridden.push(finding);
|
|
3576
|
+
} else {
|
|
3577
|
+
active.push(finding);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
return { active, overridden };
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3544
3584
|
function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
|
|
3545
|
-
const
|
|
3546
|
-
|
|
3585
|
+
const { active, overridden } = partitionSecretScanAllowOverrides(
|
|
3586
|
+
providerRoot,
|
|
3587
|
+
findSecretFindings(providerRoot, provider?.id),
|
|
3588
|
+
);
|
|
3589
|
+
const blockerFindings = active.filter((finding) => finding.level !== "warn");
|
|
3547
3590
|
if (blockerFindings.length > 0) {
|
|
3548
3591
|
return {
|
|
3549
3592
|
id: "secret-scan",
|
|
@@ -3563,7 +3606,15 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
|
|
|
3563
3606
|
),
|
|
3564
3607
|
};
|
|
3565
3608
|
}
|
|
3566
|
-
if (
|
|
3609
|
+
if (active.length > 0 || overridden.length > 0) {
|
|
3610
|
+
const messageBase =
|
|
3611
|
+
active.length > 0
|
|
3612
|
+
? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
|
|
3613
|
+
: "Potential credential-like strings were found in shareable files.";
|
|
3614
|
+
const message =
|
|
3615
|
+
overridden.length > 0
|
|
3616
|
+
? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
|
|
3617
|
+
: messageBase;
|
|
3567
3618
|
return {
|
|
3568
3619
|
id: "secret-scan",
|
|
3569
3620
|
category: "security",
|
|
@@ -3571,11 +3622,10 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
|
|
|
3571
3622
|
status: "warn",
|
|
3572
3623
|
points: 8,
|
|
3573
3624
|
maxPoints: CATEGORY_MAX_POINTS.security,
|
|
3574
|
-
message
|
|
3575
|
-
"High-entropy source strings were found without secret-like identifier context; they may be false positives.",
|
|
3625
|
+
message,
|
|
3576
3626
|
remediation:
|
|
3577
|
-
'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
|
|
3578
|
-
evidence:
|
|
3627
|
+
'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 with `// @apifuse-allow secret-scan: <reason>`.',
|
|
3628
|
+
evidence: [...active, ...overridden].map(
|
|
3579
3629
|
(finding) =>
|
|
3580
3630
|
finding.evidence ??
|
|
3581
3631
|
`${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
|
|
@@ -3606,8 +3656,19 @@ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFi
|
|
|
3606
3656
|
if (!existsSync(filePath)) continue;
|
|
3607
3657
|
const content = readFileSync(filePath, "utf8");
|
|
3608
3658
|
for (const [label, pattern] of SECRET_PATTERNS) {
|
|
3609
|
-
|
|
3610
|
-
|
|
3659
|
+
// Locate every match to its line so pattern findings carry the line
|
|
3660
|
+
// information hasAllowOverride needs: `// @apifuse-allow secret-scan`
|
|
3661
|
+
// must behave uniformly across entropy findings and pattern findings.
|
|
3662
|
+
const globalPattern = new RegExp(
|
|
3663
|
+
pattern.source,
|
|
3664
|
+
pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
|
|
3665
|
+
);
|
|
3666
|
+
const seenLines = new Set<number>();
|
|
3667
|
+
for (const match of content.matchAll(globalPattern)) {
|
|
3668
|
+
const line = offsetToLine(content, match.index);
|
|
3669
|
+
if (seenLines.has(line)) continue;
|
|
3670
|
+
seenLines.add(line);
|
|
3671
|
+
findings.push({ label, file: relativePath, line });
|
|
3611
3672
|
}
|
|
3612
3673
|
}
|
|
3613
3674
|
}
|
|
@@ -3665,7 +3726,7 @@ export function extractStringLiteralCandidates(line: string): string[] {
|
|
|
3665
3726
|
continue;
|
|
3666
3727
|
}
|
|
3667
3728
|
if (char === quote) {
|
|
3668
|
-
if (cursor - contentStart >=
|
|
3729
|
+
if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
|
|
3669
3730
|
candidates.push(line.slice(contentStart, cursor));
|
|
3670
3731
|
}
|
|
3671
3732
|
index = cursor;
|
|
@@ -3690,6 +3751,25 @@ function classifyEntropyCandidate(input: {
|
|
|
3690
3751
|
if (!charset) return undefined;
|
|
3691
3752
|
const entropy = shannonEntropy(value);
|
|
3692
3753
|
const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
|
|
3754
|
+
// Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
|
|
3755
|
+
// "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
|
|
3756
|
+
// (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
|
|
3757
|
+
// blocker-flagged. They are never skipped — entropy classification always
|
|
3758
|
+
// runs — but when the secret-ish context comes solely from identifier-
|
|
3759
|
+
// constant-shaped literal text (the line with those literals stripped
|
|
3760
|
+
// carries no secret-ish identifier), the finding is capped at a
|
|
3761
|
+
// non-blocking warning instead of a blocker. Stripping constant-shaped
|
|
3762
|
+
// siblings — not just the candidate — matters for lines holding several
|
|
3763
|
+
// constants (e.g. an ERROR_CODES array), while quoted property keys and
|
|
3764
|
+
// header names ("Authorization", "apiKey") stay visible as genuine
|
|
3765
|
+
// external context. Assignments to `apiKey`/`token`/`secret`-style names
|
|
3766
|
+
// still escalate to blockers via the identifier side, and
|
|
3767
|
+
// `// @apifuse-allow secret-scan` remains the reviewed way to silence the
|
|
3768
|
+
// warning.
|
|
3769
|
+
const selfContextOnlyConstant =
|
|
3770
|
+
secretishContext &&
|
|
3771
|
+
isScreamingSnakeConstantValue(value) &&
|
|
3772
|
+
!SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
|
|
3693
3773
|
const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
|
|
3694
3774
|
if (entropy < threshold) return undefined;
|
|
3695
3775
|
|
|
@@ -3700,16 +3780,163 @@ function classifyEntropyCandidate(input: {
|
|
|
3700
3780
|
charset === "hex"
|
|
3701
3781
|
? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
|
|
3702
3782
|
: `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
|
|
3783
|
+
const contextNote = selfContextOnlyConstant
|
|
3784
|
+
? "; identifier-like constant (downgraded to warning)"
|
|
3785
|
+
: secretishContext
|
|
3786
|
+
? ""
|
|
3787
|
+
: "; may be a false positive";
|
|
3703
3788
|
return {
|
|
3704
3789
|
label,
|
|
3705
3790
|
file: input.file,
|
|
3706
3791
|
line: input.lineNumber,
|
|
3707
|
-
level: secretishContext ? "blocker" : "warn",
|
|
3792
|
+
level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
|
|
3708
3793
|
remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
|
|
3709
|
-
evidence: `${location}: ${label}; preview ${preview}${
|
|
3794
|
+
evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
|
|
3710
3795
|
};
|
|
3711
3796
|
}
|
|
3712
3797
|
|
|
3798
|
+
// Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
|
|
3799
|
+
// separated segments, each essentially pure alphabetic — letters optionally
|
|
3800
|
+
// followed by a SHORT digit suffix (at most 2, e.g. version markers like
|
|
3801
|
+
// "V2") — and at most 15% digits across the whole value. Dictionary-style
|
|
3802
|
+
// constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
|
|
3803
|
+
// "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
|
|
3804
|
+
// (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
|
|
3805
|
+
// uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
|
|
3806
|
+
// ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
|
|
3807
|
+
// it only decides whether a finding whose secret-ish context comes solely
|
|
3808
|
+
// from the literal's own text is downgraded from blocker to warning, so it
|
|
3809
|
+
// deliberately stays strict: values that merely contain a secret-ish word but
|
|
3810
|
+
// are not word-like constants keep full blocker severity.
|
|
3811
|
+
function isScreamingSnakeConstantValue(value: string): boolean {
|
|
3812
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
|
|
3813
|
+
const segments = value.split("_");
|
|
3814
|
+
if (segments.length < 2) return false;
|
|
3815
|
+
if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
|
|
3816
|
+
const digitCount = value.match(/[0-9]/g)?.length ?? 0;
|
|
3817
|
+
return digitCount / value.length <= 0.15;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
type LineStringLiteral = {
|
|
3821
|
+
// Index of the opening quote.
|
|
3822
|
+
start: number;
|
|
3823
|
+
// Index just past the closing quote (line end when unterminated).
|
|
3824
|
+
end: number;
|
|
3825
|
+
content: string;
|
|
3826
|
+
closed: boolean;
|
|
3827
|
+
role: "key" | "value";
|
|
3828
|
+
// Nearest unclosed bracket enclosing the literal's start, if any.
|
|
3829
|
+
container?: { bracket: "[" | "(" | "{"; index: number };
|
|
3830
|
+
};
|
|
3831
|
+
|
|
3832
|
+
// Stable identity for the container a literal sits in ("top" when the
|
|
3833
|
+
// literal is not inside any bracket on the line).
|
|
3834
|
+
function literalContainerKey(literal: LineStringLiteral): string {
|
|
3835
|
+
return literal.container
|
|
3836
|
+
? `${literal.container.bracket}${literal.container.index}`
|
|
3837
|
+
: "top";
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
// Single-pass line tokenizer: extracts every string literal with its span and
|
|
3841
|
+
// classifies its syntactic role once. A literal is a KEY when it is preceded
|
|
3842
|
+
// (ignoring whitespace) by "{", ",", "(", or the line start AND followed
|
|
3843
|
+
// (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
|
|
3844
|
+
// other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
|
|
3845
|
+
// elements, call arguments, and assignment right-hand sides, even when a
|
|
3846
|
+
// ternary's ":" happens to follow them. Uses the same quote/escape walking as
|
|
3847
|
+
// extractStringLiteralCandidates.
|
|
3848
|
+
function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
|
|
3849
|
+
const literals: LineStringLiteral[] = [];
|
|
3850
|
+
const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
|
|
3851
|
+
let index = 0;
|
|
3852
|
+
while (index < line.length) {
|
|
3853
|
+
const char = line[index];
|
|
3854
|
+
if (char !== '"' && char !== "'" && char !== "`") {
|
|
3855
|
+
if (char === "[" || char === "(" || char === "{") {
|
|
3856
|
+
bracketStack.push({ bracket: char, index });
|
|
3857
|
+
} else if (char === "]" || char === ")" || char === "}") {
|
|
3858
|
+
bracketStack.pop();
|
|
3859
|
+
}
|
|
3860
|
+
index += 1;
|
|
3861
|
+
continue;
|
|
3862
|
+
}
|
|
3863
|
+
const quote = char;
|
|
3864
|
+
const start = index;
|
|
3865
|
+
const contentStart = index + 1;
|
|
3866
|
+
let cursor = contentStart;
|
|
3867
|
+
let closed = false;
|
|
3868
|
+
while (cursor < line.length) {
|
|
3869
|
+
const inner = line[cursor];
|
|
3870
|
+
if (inner === "\\") {
|
|
3871
|
+
cursor += 2;
|
|
3872
|
+
continue;
|
|
3873
|
+
}
|
|
3874
|
+
if (inner === quote) {
|
|
3875
|
+
closed = true;
|
|
3876
|
+
break;
|
|
3877
|
+
}
|
|
3878
|
+
cursor += 1;
|
|
3879
|
+
}
|
|
3880
|
+
const contentEnd = Math.min(cursor, line.length);
|
|
3881
|
+
const end = closed ? cursor + 1 : line.length;
|
|
3882
|
+
const before = line.slice(0, start).trimEnd();
|
|
3883
|
+
const keyPreceded = before === "" || /[{,(]$/.test(before);
|
|
3884
|
+
const keyFollowed = closed && /^\s*:/.test(line.slice(end));
|
|
3885
|
+
literals.push({
|
|
3886
|
+
start,
|
|
3887
|
+
end,
|
|
3888
|
+
content: line.slice(contentStart, contentEnd),
|
|
3889
|
+
closed,
|
|
3890
|
+
role: keyPreceded && keyFollowed ? "key" : "value",
|
|
3891
|
+
container: bracketStack[bracketStack.length - 1],
|
|
3892
|
+
});
|
|
3893
|
+
index = end;
|
|
3894
|
+
}
|
|
3895
|
+
return literals;
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
// Builds the context text used to decide whether a candidate's secret-ish
|
|
3899
|
+
// context is genuine. The candidate's own literal is ALWAYS stripped
|
|
3900
|
+
// (self-context rule). SIBLING identifier-constant-shaped candidate literals
|
|
3901
|
+
// (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
|
|
3902
|
+
// stripped only when they share the candidate's non-call container — the same
|
|
3903
|
+
// `[...]` array, the same `{...}` object value list, or the bracket-free top
|
|
3904
|
+
// level (ternary arms) — so a value list of error codes cannot poison its own
|
|
3905
|
+
// members' context. Call-argument siblings (inside `(...)`) always keep their
|
|
3906
|
+
// context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
|
|
3907
|
+
// argument genuinely describes the second, so stripping it would erase real
|
|
3908
|
+
// auth/token context. KEY-role literals are never stripped.
|
|
3909
|
+
function stripIdentifierConstantLiterals(line: string, candidate: string): string {
|
|
3910
|
+
const literals = tokenizeLineStringLiterals(line);
|
|
3911
|
+
const candidateContainers = new Set<string>();
|
|
3912
|
+
for (const literal of literals) {
|
|
3913
|
+
if (literal.content === candidate) {
|
|
3914
|
+
candidateContainers.add(literalContainerKey(literal));
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
let result = "";
|
|
3918
|
+
let previousEnd = 0;
|
|
3919
|
+
for (const literal of literals) {
|
|
3920
|
+
result += line.slice(previousEnd, literal.start);
|
|
3921
|
+
const isSelf = literal.content === candidate;
|
|
3922
|
+
const isSameNonCallContainerSibling =
|
|
3923
|
+
literal.role === "value" &&
|
|
3924
|
+
literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
|
|
3925
|
+
isScreamingSnakeConstantValue(literal.content) &&
|
|
3926
|
+
literal.container?.bracket !== "(" &&
|
|
3927
|
+
candidateContainers.has(literalContainerKey(literal));
|
|
3928
|
+
if (isSelf || isSameNonCallContainerSibling) {
|
|
3929
|
+
const quote = line[literal.start] ?? "";
|
|
3930
|
+
result += quote + (literal.closed ? quote : "");
|
|
3931
|
+
} else {
|
|
3932
|
+
result += line.slice(literal.start, literal.end);
|
|
3933
|
+
}
|
|
3934
|
+
previousEnd = literal.end;
|
|
3935
|
+
}
|
|
3936
|
+
result += line.slice(previousEnd);
|
|
3937
|
+
return result;
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3713
3940
|
function shouldConsiderEntropyValue(value: string): boolean {
|
|
3714
3941
|
const lower = value.toLowerCase();
|
|
3715
3942
|
if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
|
|
@@ -3725,7 +3952,7 @@ function shouldConsiderEntropyValue(value: string): boolean {
|
|
|
3725
3952
|
if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
|
|
3726
3953
|
return false;
|
|
3727
3954
|
}
|
|
3728
|
-
return value.length >=
|
|
3955
|
+
return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
|
|
3729
3956
|
}
|
|
3730
3957
|
|
|
3731
3958
|
function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
|
|
@@ -3759,6 +3986,11 @@ function guessSecretName(line: string): string {
|
|
|
3759
3986
|
|
|
3760
3987
|
const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
|
|
3761
3988
|
|
|
3989
|
+
// Minimum length for a string literal to be considered an entropy candidate.
|
|
3990
|
+
// Shared by candidate extraction, entropy screening, and the context strip so
|
|
3991
|
+
// the three stay coherent.
|
|
3992
|
+
const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
|
|
3993
|
+
|
|
3762
3994
|
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
3763
3995
|
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
3764
3996
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
package/bin/apifuse.ts
CHANGED
package/bin/submit-check-xml.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
normalizedXmlName,
|
|
15
15
|
rootXmlContext,
|
|
16
16
|
type XmlSemanticBranch,
|
|
17
|
-
} from "./submit-check-xml-semantics";
|
|
17
|
+
} from "./submit-check-xml-semantics.js";
|
|
18
18
|
|
|
19
19
|
const MIN_RECORDED_XML_LENGTH = 128;
|
|
20
20
|
// Recorded fixtures must remain reviewable; this pre-allocation cap also bounds the parser tree.
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AuthError } from "./errors";
|
|
2
|
-
import type { AuthConfig, AuthFlowTerminalContext, AuthTurn, ContextDeclaration, CredentialDeclaration, FlowContext, ProviderLocaleKeyInput } from "./types";
|
|
1
|
+
import { AuthError } from "./errors.js";
|
|
2
|
+
import type { AuthConfig, AuthFlowTerminalContext, AuthTurn, ContextDeclaration, CredentialDeclaration, FlowContext, ProviderLocaleKeyInput } from "./types.js";
|
|
3
3
|
export type CredentialsAuthFieldType = "string" | "email" | "password" | "otp";
|
|
4
4
|
export interface CredentialsAuthField {
|
|
5
5
|
type?: CredentialsAuthFieldType;
|