@apifuse/provider-sdk 2.1.0-beta.9 → 2.2.0-beta.10
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 +240 -0
- package/CHANGELOG.md +93 -0
- package/README.md +26 -10
- package/SUBMISSION.md +11 -12
- package/bin/apifuse-check.ts +44 -59
- package/bin/apifuse-create.ts +1 -1
- package/bin/apifuse-dev.ts +27 -52
- package/bin/apifuse-pack-check.ts +36 -0
- 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 +53 -70
- package/bin/apifuse-submit-check.ts +2177 -353
- package/bin/apifuse-sync-assets.ts +117 -0
- package/bin/apifuse.ts +1 -1
- package/bin/submit-check-delimited-text.ts +50 -0
- package/bin/submit-check-xml-semantics.ts +204 -0
- package/bin/submit-check-xml.ts +134 -0
- package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
- package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
- package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
- package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
- package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
- package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
- package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
- package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
- package/dist/auth-turn/fixtures/valid/abort.json +8 -0
- package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
- package/dist/auth-turn/fixtures/valid/complete.json +13 -0
- package/dist/auth-turn/fixtures/valid/form.json +14 -0
- package/dist/auth-turn/fixtures/valid/message.json +13 -0
- package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
- package/dist/auth-turn/fixtures/valid/pending.json +5 -0
- package/dist/auth-turn/fixtures/valid/poll.json +9 -0
- package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
- package/dist/auth-turn/fixtures/valid/retry.json +8 -0
- package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
- package/dist/auth-turn/index.d.ts +195 -0
- package/dist/auth-turn/index.js +133 -0
- package/dist/auth.d.ts +76 -0
- package/dist/auth.js +427 -0
- package/dist/ceremonies/index.d.ts +1 -1
- package/dist/ceremonies/index.js +14 -48
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +8 -0
- package/dist/cli/create.d.ts +3 -0
- package/dist/cli/create.js +47 -33
- package/dist/cli/prompt-assets.d.ts +80 -0
- package/dist/cli/prompt-assets.js +743 -0
- package/dist/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
- package/dist/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
- package/dist/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
- package/dist/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
- package/dist/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
- package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
- package/dist/cli/templates/provider/.gitignore.tpl +22 -0
- package/dist/cli/templates/provider/AGENTS.md.tpl +96 -0
- package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
- package/dist/cli/templates/provider/README.md.tpl +163 -0
- package/dist/cli/templates/provider/dev.ts.tpl +5 -0
- package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
- package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
- package/dist/cli/templates/provider/index.ts.tpl +15 -0
- package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
- package/dist/cli/templates/provider/meta.ts.tpl +7 -0
- package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
- package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
- package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
- package/dist/cli/templates/provider/start.ts.tpl +5 -0
- package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
- package/dist/config/loader.d.ts +149 -8
- package/dist/config/loader.js +378 -89
- 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 +5 -6
- package/dist/define.d.ts +13 -1
- package/dist/define.js +245 -178
- package/dist/dev.d.ts +1 -1
- package/dist/dev.js +1 -1
- package/dist/errors.d.ts +4 -1
- package/dist/errors.js +48 -0
- 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 +44 -41
- package/dist/index.js +39 -36
- package/dist/lint.d.ts +1 -0
- package/dist/lint.js +35 -15
- package/dist/provider.d.ts +11 -9
- package/dist/provider.js +9 -8
- 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 +4 -2
- package/dist/runtime/browser.d.ts +1 -1
- package/dist/runtime/browser.js +214 -25
- 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 +31 -35
- 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 +15 -3
- package/dist/runtime/http.d.ts +2 -2
- package/dist/runtime/http.js +160 -344
- 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 +56 -19
- 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 +9 -7
- package/dist/runtime/proxy-nodemaven.d.ts +35 -0
- package/dist/runtime/proxy-nodemaven.js +128 -0
- package/dist/runtime/proxy-retry-policy.d.ts +40 -0
- package/dist/runtime/proxy-retry-policy.js +326 -0
- package/dist/runtime/proxy-telemetry.d.ts +2 -1
- package/dist/runtime/proxy-telemetry.js +55 -52
- 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/secrets.d.ts +27 -0
- package/dist/runtime/secrets.js +51 -0
- package/dist/runtime/state.d.ts +2 -2
- package/dist/runtime/state.js +15 -4
- package/dist/runtime/stealth.d.ts +7 -4
- package/dist/runtime/stealth.js +257 -215
- 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 -3
- package/dist/server/index.js +6 -2
- package/dist/server/self-test-input-tokens.d.ts +1 -0
- package/dist/server/self-test-input-tokens.js +37 -0
- package/dist/server/self-test-redaction.d.ts +20 -0
- package/dist/server/self-test-redaction.js +70 -0
- package/dist/server/self-test-token.d.ts +30 -0
- package/dist/server/self-test-token.js +50 -0
- package/dist/server/self-test.d.ts +199 -0
- package/dist/server/self-test.js +1113 -0
- package/dist/server/serve.d.ts +14 -3
- package/dist/server/serve.js +135 -64
- package/dist/server/types.d.ts +10 -9
- package/dist/server/types.js +3 -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 +12 -15
- package/dist/types.d.ts +237 -1
- package/dist/user-input.d.ts +30 -0
- package/dist/user-input.js +66 -0
- package/package.json +16 -5
- package/src/auth-turn/auth-turn.v1.schema.json +89 -0
- package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
- package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
- package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
- package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
- package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
- package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
- package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
- package/src/auth-turn/fixtures/valid/abort.json +8 -0
- package/src/auth-turn/fixtures/valid/challenge.json +17 -0
- package/src/auth-turn/fixtures/valid/complete.json +13 -0
- package/src/auth-turn/fixtures/valid/form.json +14 -0
- package/src/auth-turn/fixtures/valid/message.json +13 -0
- package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
- package/src/auth-turn/fixtures/valid/pending.json +5 -0
- package/src/auth-turn/fixtures/valid/poll.json +9 -0
- package/src/auth-turn/fixtures/valid/redirect.json +16 -0
- package/src/auth-turn/fixtures/valid/retry.json +8 -0
- package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
- package/src/auth-turn/index.ts +177 -0
- package/src/auth.ts +728 -0
- package/src/ceremonies/index.ts +33 -121
- package/src/cli/commands.ts +10 -0
- package/src/cli/create.ts +69 -99
- package/src/cli/prompt-assets.ts +865 -0
- package/src/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
- package/src/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
- package/src/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
- package/src/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
- package/src/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
- package/src/cli/templates/provider/AGENTS.md.tpl +96 -0
- package/src/cli/templates/provider/README.md.tpl +7 -4
- package/src/config/loader.ts +543 -208
- package/src/contract-serialization.ts +5 -11
- package/src/contract-types.ts +2 -2
- package/src/contract.ts +13 -28
- package/src/define.ts +397 -528
- package/src/dev.ts +4 -9
- package/src/errors.ts +58 -5
- 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 +64 -41
- package/src/lint.ts +122 -159
- package/src/provider.ts +40 -9
- package/src/public-schema-field-lint.ts +7 -33
- package/src/recipes/gov-api.ts +2 -5
- package/src/runtime/auth-flow.ts +6 -6
- package/src/runtime/browser.ts +320 -151
- package/src/runtime/cache.ts +20 -67
- package/src/runtime/choice.ts +79 -132
- package/src/runtime/credential.ts +2 -2
- package/src/runtime/env.ts +1 -1
- package/src/runtime/executor.ts +23 -20
- package/src/runtime/http.ts +216 -539
- package/src/runtime/insights.ts +15 -53
- package/src/runtime/instrumentation.ts +78 -65
- 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 +29 -42
- package/src/runtime/proxy-nodemaven.ts +178 -0
- package/src/runtime/proxy-retry-policy.ts +469 -0
- package/src/runtime/proxy-telemetry.ts +79 -77
- package/src/runtime/redis.ts +4 -12
- package/src/runtime/request-options.ts +4 -13
- package/src/runtime/secrets.ts +64 -0
- package/src/runtime/state.ts +41 -110
- package/src/runtime/stealth.ts +331 -369
- 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 +44 -3
- package/src/server/self-test-input-tokens.ts +46 -0
- package/src/server/self-test-redaction.ts +97 -0
- package/src/server/self-test-token.ts +70 -0
- package/src/server/self-test.ts +1450 -0
- package/src/server/serve.ts +206 -216
- package/src/server/types.ts +7 -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 +24 -64
- package/src/types.ts +274 -1
- package/src/user-input.ts +118 -0
|
@@ -1,21 +1,30 @@
|
|
|
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
|
|
|
10
|
+
import * as acorn from "acorn";
|
|
8
11
|
import { z } from "zod";
|
|
9
12
|
|
|
10
13
|
import packageJson from "../package.json";
|
|
11
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
formatPromptAssetIssues,
|
|
16
|
+
verifyPromptAssets,
|
|
17
|
+
} from "../src/cli/prompt-assets.js";
|
|
12
18
|
import {
|
|
13
19
|
loadProviderLocaleCatalogs,
|
|
14
20
|
type ProviderLocale,
|
|
15
21
|
validateProviderLocaleCatalogs,
|
|
16
|
-
} from "../src/i18n";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
22
|
+
} from "../src/i18n/index.js";
|
|
23
|
+
import type { ProviderDefinition } from "../src/index.js";
|
|
24
|
+
import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
|
|
25
|
+
import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
|
|
26
|
+
import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
|
|
27
|
+
import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
|
|
19
28
|
|
|
20
29
|
const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
|
|
21
30
|
const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
|
|
@@ -35,6 +44,7 @@ export type SubmitCheck = {
|
|
|
35
44
|
message: string;
|
|
36
45
|
remediation?: string;
|
|
37
46
|
evidence?: string[];
|
|
47
|
+
details?: unknown;
|
|
38
48
|
};
|
|
39
49
|
|
|
40
50
|
export type SubmitCheckReport = {
|
|
@@ -69,6 +79,7 @@ type CliArgs = {
|
|
|
69
79
|
isJson: boolean;
|
|
70
80
|
markdownPath?: string;
|
|
71
81
|
providerPath?: string;
|
|
82
|
+
smoke: boolean;
|
|
72
83
|
smokeNote?: string;
|
|
73
84
|
tier?: BountyTier;
|
|
74
85
|
};
|
|
@@ -76,6 +87,24 @@ type CliArgs = {
|
|
|
76
87
|
type SecretFinding = {
|
|
77
88
|
label: string;
|
|
78
89
|
file: string;
|
|
90
|
+
line?: number;
|
|
91
|
+
level?: CheckLevel;
|
|
92
|
+
remediation?: string;
|
|
93
|
+
evidence?: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type SmokeOperationOutcome = {
|
|
97
|
+
operationId: string;
|
|
98
|
+
status: "success" | "structured_error" | "incoherent";
|
|
99
|
+
httpStatus?: number;
|
|
100
|
+
message: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type SmokeResult = {
|
|
104
|
+
measured: true;
|
|
105
|
+
healthOk: boolean;
|
|
106
|
+
bootError?: string;
|
|
107
|
+
operations: SmokeOperationOutcome[];
|
|
79
108
|
};
|
|
80
109
|
|
|
81
110
|
type SourceFinding = {
|
|
@@ -98,14 +127,13 @@ const CATEGORY_MAX_POINTS = {
|
|
|
98
127
|
docs: 10,
|
|
99
128
|
} as const;
|
|
100
129
|
|
|
101
|
-
const REQUIRED_PUBLIC_PROVIDER_LOCALES = [
|
|
102
|
-
"en",
|
|
103
|
-
"ko",
|
|
104
|
-
] as const satisfies readonly ProviderLocale[];
|
|
130
|
+
const REQUIRED_PUBLIC_PROVIDER_LOCALES = ["en", "ko"] as const satisfies readonly ProviderLocale[];
|
|
105
131
|
|
|
106
|
-
const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke
|
|
132
|
+
const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke]
|
|
107
133
|
Alias: apifuse bounty-check [path]
|
|
108
|
-
Default: apifuse submit-check
|
|
134
|
+
Default: apifuse submit-check .
|
|
135
|
+
|
|
136
|
+
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
137
|
|
|
110
138
|
export async function main() {
|
|
111
139
|
try {
|
|
@@ -120,10 +148,7 @@ export async function main() {
|
|
|
120
148
|
const report = await buildSubmitCheckReport(providerRoot, args);
|
|
121
149
|
|
|
122
150
|
if (args.markdownPath) {
|
|
123
|
-
await writeFile(
|
|
124
|
-
resolve(process.cwd(), args.markdownPath),
|
|
125
|
-
renderMarkdown(report),
|
|
126
|
-
);
|
|
151
|
+
await writeFile(resolve(process.cwd(), args.markdownPath), renderMarkdown(report));
|
|
127
152
|
}
|
|
128
153
|
|
|
129
154
|
if (args.isJson) {
|
|
@@ -150,7 +175,7 @@ function normalizeArgs(argv: string[]): string[] {
|
|
|
150
175
|
}
|
|
151
176
|
|
|
152
177
|
function parseArgs(argv: string[]): CliArgs {
|
|
153
|
-
const args: CliArgs = { isJson: false };
|
|
178
|
+
const args: CliArgs = { isJson: false, smoke: false };
|
|
154
179
|
|
|
155
180
|
for (let index = 0; index < argv.length; index += 1) {
|
|
156
181
|
const arg = argv[index];
|
|
@@ -177,6 +202,11 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
177
202
|
continue;
|
|
178
203
|
}
|
|
179
204
|
|
|
205
|
+
if (arg === "--smoke") {
|
|
206
|
+
args.smoke = true;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
180
210
|
if (arg === "--smoke-note") {
|
|
181
211
|
args.smokeNote = requireValue(argv, index, arg);
|
|
182
212
|
index += 1;
|
|
@@ -226,9 +256,7 @@ function parseTier(value: string): BountyTier {
|
|
|
226
256
|
if (isBountyTier(value)) {
|
|
227
257
|
return value;
|
|
228
258
|
}
|
|
229
|
-
throw new Error(
|
|
230
|
-
`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`,
|
|
231
|
-
);
|
|
259
|
+
throw new Error(`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`);
|
|
232
260
|
}
|
|
233
261
|
|
|
234
262
|
function isBountyTier(value: string): value is BountyTier {
|
|
@@ -237,13 +265,21 @@ function isBountyTier(value: string): value is BountyTier {
|
|
|
237
265
|
|
|
238
266
|
export async function buildSubmitCheckReport(
|
|
239
267
|
providerRoot: string,
|
|
240
|
-
args: { smokeNote?: string; tier?: BountyTier } = {},
|
|
268
|
+
args: { smoke?: boolean; smokeNote?: string; tier?: BountyTier } = {},
|
|
241
269
|
): Promise<SubmitCheckReport> {
|
|
242
270
|
const checks: SubmitCheck[] = [];
|
|
243
271
|
const baseChecks = await safeRunChecks(providerRoot);
|
|
244
272
|
const provider = await safeLoadProvider(providerRoot);
|
|
245
273
|
|
|
246
|
-
|
|
274
|
+
// Prompt-asset freshness is reported by its own dedicated zero-point
|
|
275
|
+
// blocker below; filter the base-check duplicate so it is not double
|
|
276
|
+
// penalized under the definition category.
|
|
277
|
+
checks.push(
|
|
278
|
+
...scoreBaseChecks(
|
|
279
|
+
baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
checks.push(scorePromptAssetFreshness(providerRoot));
|
|
247
283
|
checks.push(scoreProviderIdSlug(providerRoot, provider));
|
|
248
284
|
checks.push(scoreNoVendorShim(providerRoot));
|
|
249
285
|
checks.push(scoreNoVendorImport(providerRoot));
|
|
@@ -257,16 +293,21 @@ export async function buildSubmitCheckReport(
|
|
|
257
293
|
checks.push(scoreFlatOperationComposition(providerRoot));
|
|
258
294
|
|
|
259
295
|
if (provider) {
|
|
296
|
+
const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
|
|
260
297
|
checks.push(scoreCredentialUsage(providerRoot, provider));
|
|
298
|
+
checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
|
|
261
299
|
checks.push(scoreLocaleCatalog(providerRoot, provider));
|
|
262
300
|
checks.push(scoreOperationMetadata(provider));
|
|
263
301
|
checks.push(scoreFixtureCoverage(provider));
|
|
302
|
+
checks.push(scoreFixtureProvenance(providerRoot, provider));
|
|
303
|
+
checks.push(scoreVendorKeyLeak(providerRoot));
|
|
304
|
+
checks.push(scoreVendorTimestampLeak(providerRoot));
|
|
264
305
|
checks.push(scoreHealthCoverage(provider));
|
|
265
306
|
checks.push(scoreAuthSafety(provider));
|
|
266
|
-
checks.push(
|
|
307
|
+
checks.push(scoreSmoke(smokeResult, args.smokeNote));
|
|
267
308
|
checks.push(...scoreProviderDocs(providerRoot));
|
|
268
309
|
checks.push(scoreRepositoryDx(providerRoot));
|
|
269
|
-
checks.push(scoreSecrets(providerRoot));
|
|
310
|
+
checks.push(scoreSecrets(providerRoot, provider));
|
|
270
311
|
} else {
|
|
271
312
|
checks.push(
|
|
272
313
|
blocker(
|
|
@@ -279,22 +320,14 @@ export async function buildSubmitCheckReport(
|
|
|
279
320
|
);
|
|
280
321
|
}
|
|
281
322
|
|
|
282
|
-
const total = clamp(
|
|
283
|
-
Math.round(checks.reduce((sum, check) => sum + check.points, 0)),
|
|
284
|
-
0,
|
|
285
|
-
100,
|
|
286
|
-
);
|
|
323
|
+
const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
|
|
287
324
|
const blockers = checks.filter(
|
|
288
325
|
(check) => check.level === "blocker" && check.status === "fail",
|
|
289
326
|
).length;
|
|
290
327
|
const warnings = checks.filter((check) => check.status === "warn").length;
|
|
291
328
|
const passed = checks.filter((check) => check.status === "pass").length;
|
|
292
329
|
const verdict: Verdict =
|
|
293
|
-
blockers > 0
|
|
294
|
-
? "blocked"
|
|
295
|
-
: total >= 90 && warnings === 0
|
|
296
|
-
? "ready"
|
|
297
|
-
: "reviewable_with_warnings";
|
|
330
|
+
blockers > 0 ? "blocked" : total >= 90 && warnings === 0 ? "ready" : "reviewable_with_warnings";
|
|
298
331
|
|
|
299
332
|
return {
|
|
300
333
|
schemaVersion: 1,
|
|
@@ -335,18 +368,10 @@ function scoreProviderIdSlug(
|
|
|
335
368
|
);
|
|
336
369
|
}
|
|
337
370
|
|
|
338
|
-
return pass(
|
|
339
|
-
"id-slug",
|
|
340
|
-
SDK_NATIVE_CATEGORY,
|
|
341
|
-
"Provider id uses the short slug.",
|
|
342
|
-
0,
|
|
343
|
-
);
|
|
371
|
+
return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
|
|
344
372
|
}
|
|
345
373
|
|
|
346
|
-
const findings = findSourceLineMatches(
|
|
347
|
-
providerRoot,
|
|
348
|
-
/["'`]apifuse-provider-[a-z0-9-]/i,
|
|
349
|
-
);
|
|
374
|
+
const findings = findSourceLineMatches(providerRoot, /["'`]apifuse-provider-[a-z0-9-]/i);
|
|
350
375
|
if (findings.length > 0) {
|
|
351
376
|
return blocker(
|
|
352
377
|
"id-slug",
|
|
@@ -358,12 +383,7 @@ function scoreProviderIdSlug(
|
|
|
358
383
|
);
|
|
359
384
|
}
|
|
360
385
|
|
|
361
|
-
return pass(
|
|
362
|
-
"id-slug",
|
|
363
|
-
SDK_NATIVE_CATEGORY,
|
|
364
|
-
"Provider id uses the short slug.",
|
|
365
|
-
0,
|
|
366
|
-
);
|
|
386
|
+
return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
|
|
367
387
|
}
|
|
368
388
|
|
|
369
389
|
function scoreNoVendorShim(providerRoot: string): SubmitCheck {
|
|
@@ -388,10 +408,7 @@ function scoreNoVendorShim(providerRoot: string): SubmitCheck {
|
|
|
388
408
|
}
|
|
389
409
|
|
|
390
410
|
function scoreNoVendorImport(providerRoot: string): SubmitCheck {
|
|
391
|
-
const findings = findSourceLineMatches(
|
|
392
|
-
providerRoot,
|
|
393
|
-
/from\s+["'][^"']*vendor\//,
|
|
394
|
-
);
|
|
411
|
+
const findings = findSourceLineMatches(providerRoot, /from\s+["'][^"']*vendor\//);
|
|
395
412
|
if (findings.length > 0) {
|
|
396
413
|
return blocker(
|
|
397
414
|
"no-vendor-import",
|
|
@@ -424,33 +441,24 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
|
|
|
424
441
|
);
|
|
425
442
|
}
|
|
426
443
|
|
|
427
|
-
return pass(
|
|
428
|
-
"describe-key",
|
|
429
|
-
SDK_NATIVE_CATEGORY,
|
|
430
|
-
"Schema descriptions use describeKey.",
|
|
431
|
-
0,
|
|
432
|
-
);
|
|
444
|
+
return pass("describe-key", SDK_NATIVE_CATEGORY, "Schema descriptions use describeKey.", 0);
|
|
433
445
|
}
|
|
434
446
|
|
|
435
447
|
function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
436
448
|
const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
|
|
437
449
|
if (findings.length > 0) {
|
|
450
|
+
const evidence = formatSourceFindings(findings);
|
|
438
451
|
return blocker(
|
|
439
452
|
"no-raw-fetch",
|
|
440
453
|
SDK_NATIVE_CATEGORY,
|
|
441
454
|
"Provider source calls raw fetch().",
|
|
442
|
-
|
|
455
|
+
`Replace raw fetch() in ${evidence.join(", ")} with ctx.stealth.fetch() for stealth/cloud-IP-sensitive calls or ctx.http.get/post/request for ordinary HTTP calls.`,
|
|
443
456
|
0,
|
|
444
|
-
|
|
457
|
+
evidence,
|
|
445
458
|
);
|
|
446
459
|
}
|
|
447
460
|
|
|
448
|
-
return pass(
|
|
449
|
-
"no-raw-fetch",
|
|
450
|
-
SDK_NATIVE_CATEGORY,
|
|
451
|
-
"Provider source avoids raw fetch().",
|
|
452
|
-
0,
|
|
453
|
-
);
|
|
461
|
+
return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
|
|
454
462
|
}
|
|
455
463
|
|
|
456
464
|
const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
@@ -460,10 +468,7 @@ const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
|
460
468
|
const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
|
|
461
469
|
/\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
|
|
462
470
|
|
|
463
|
-
function hasRedundantRuntimeGuard(
|
|
464
|
-
line: string,
|
|
465
|
-
remainingLines: readonly string[],
|
|
466
|
-
): boolean {
|
|
471
|
+
function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
|
|
467
472
|
if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
|
|
468
473
|
return true;
|
|
469
474
|
}
|
|
@@ -474,12 +479,8 @@ function hasRedundantRuntimeGuard(
|
|
|
474
479
|
return false;
|
|
475
480
|
}
|
|
476
481
|
|
|
477
|
-
const guardPattern = new RegExp(
|
|
478
|
-
|
|
479
|
-
);
|
|
480
|
-
return remainingLines
|
|
481
|
-
.slice(0, 8)
|
|
482
|
-
.some((candidate) => guardPattern.test(candidate));
|
|
482
|
+
const guardPattern = new RegExp(`(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`);
|
|
483
|
+
return remainingLines.slice(0, 8).some((candidate) => guardPattern.test(candidate));
|
|
483
484
|
}
|
|
484
485
|
|
|
485
486
|
function scoreNoRedundantRuntimeGuards(providerRoot: string): SubmitCheck {
|
|
@@ -580,11 +581,7 @@ function scoreAsAssertionCount(providerRoot: string): SubmitCheck {
|
|
|
580
581
|
|
|
581
582
|
// Returns true when `findingLine` (1-based) or the line directly above it
|
|
582
583
|
// carries an `// @apifuse-allow <ruleId>:` acknowledgement comment.
|
|
583
|
-
function hasAllowOverride(
|
|
584
|
-
lines: readonly string[],
|
|
585
|
-
findingLine: number,
|
|
586
|
-
ruleId: string,
|
|
587
|
-
): boolean {
|
|
584
|
+
function hasAllowOverride(lines: readonly string[], findingLine: number, ruleId: string): boolean {
|
|
588
585
|
const pattern = new RegExp(`@apifuse-allow\\s+${ruleId}\\b`);
|
|
589
586
|
const current = lines[findingLine - 1];
|
|
590
587
|
const previous = lines[findingLine - 2];
|
|
@@ -635,11 +632,7 @@ function escapeHatchResult(
|
|
|
635
632
|
return pass(ruleId, SDK_NATIVE_CATEGORY, copy.passMessage, 0);
|
|
636
633
|
}
|
|
637
634
|
|
|
638
|
-
const { violations, overridden } = partitionAllowOverrides(
|
|
639
|
-
providerRoot,
|
|
640
|
-
findings,
|
|
641
|
-
ruleId,
|
|
642
|
-
);
|
|
635
|
+
const { violations, overridden } = partitionAllowOverrides(providerRoot, findings, ruleId);
|
|
643
636
|
|
|
644
637
|
if (violations.length > 0) {
|
|
645
638
|
return blocker(
|
|
@@ -752,10 +745,11 @@ function unwrapParens(expr: string): string {
|
|
|
752
745
|
// closing bracket. This lets a property value be read across newlines, so a
|
|
753
746
|
// multi-line `input: z.object({...})\n.passthrough()` is captured whole.
|
|
754
747
|
function balancedValueExpression(source: string, valueStart: number): string {
|
|
748
|
+
const masked = maskCommentsAndStrings(source);
|
|
755
749
|
let depth = 0;
|
|
756
750
|
let index = valueStart;
|
|
757
751
|
for (; index < source.length; index += 1) {
|
|
758
|
-
const ch =
|
|
752
|
+
const ch = masked[index];
|
|
759
753
|
if (ch === "(" || ch === "{" || ch === "[") {
|
|
760
754
|
depth += 1;
|
|
761
755
|
} else if (ch === ")" || ch === "}" || ch === "]") {
|
|
@@ -952,9 +946,7 @@ function inputKeyIsSchemaField(source: string, propIndex: number): boolean {
|
|
|
952
946
|
// across unrelated modules from producing false positives).
|
|
953
947
|
function fileImportsBinding(source: string, name: string): boolean {
|
|
954
948
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
955
|
-
return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(
|
|
956
|
-
source,
|
|
957
|
-
);
|
|
949
|
+
return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(source);
|
|
958
950
|
}
|
|
959
951
|
|
|
960
952
|
// Resolves the ORIGINAL exported name for a local binding `localName`. When the
|
|
@@ -998,11 +990,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
998
990
|
passthroughByFile.set(filePath, localMap);
|
|
999
991
|
const constDecl =
|
|
1000
992
|
/(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
|
|
1001
|
-
for (
|
|
1002
|
-
let match = constDecl.exec(source);
|
|
1003
|
-
match !== null;
|
|
1004
|
-
match = constDecl.exec(source)
|
|
1005
|
-
) {
|
|
993
|
+
for (let match = constDecl.exec(source); match !== null; match = constDecl.exec(source)) {
|
|
1006
994
|
const name = match[1];
|
|
1007
995
|
if (name === undefined) {
|
|
1008
996
|
continue;
|
|
@@ -1041,11 +1029,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1041
1029
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1042
1030
|
|
|
1043
1031
|
const inputProp = /\binput\s*:\s*/g;
|
|
1044
|
-
for (
|
|
1045
|
-
let match = inputProp.exec(source);
|
|
1046
|
-
match !== null;
|
|
1047
|
-
match = inputProp.exec(source)
|
|
1048
|
-
) {
|
|
1032
|
+
for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
|
|
1049
1033
|
// Skip `input` keys that are fields inside a zod schema body (e.g. an
|
|
1050
1034
|
// upstream payload modelled as `z.object({ input: ... })`). Only an
|
|
1051
1035
|
// operation's public `input:` property is in scope for this rule.
|
|
@@ -1073,9 +1057,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1073
1057
|
// Imported binding: map a possible `orig as refName` alias
|
|
1074
1058
|
// back to the exported name the provider-wide map is keyed by.
|
|
1075
1059
|
const originalName = importedOriginalName(source, refName);
|
|
1076
|
-
const site =
|
|
1077
|
-
passthroughConsts.get(refName) ??
|
|
1078
|
-
passthroughConsts.get(originalName);
|
|
1060
|
+
const site = passthroughConsts.get(refName) ?? passthroughConsts.get(originalName);
|
|
1079
1061
|
if (site) {
|
|
1080
1062
|
push(site);
|
|
1081
1063
|
}
|
|
@@ -1121,8 +1103,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
|
|
|
1121
1103
|
// A `//` justification comment on the same line or the line above
|
|
1122
1104
|
// (including the `@apifuse-allow loose-schema:` form) acknowledges it.
|
|
1123
1105
|
const previous = lines[index - 1];
|
|
1124
|
-
const justified =
|
|
1125
|
-
line.includes("//") || previous?.trim().startsWith("//") === true;
|
|
1106
|
+
const justified = line.includes("//") || previous?.trim().startsWith("//") === true;
|
|
1126
1107
|
if (!justified) {
|
|
1127
1108
|
findings.push({
|
|
1128
1109
|
file: toRelativeProviderPath(providerRoot, filePath),
|
|
@@ -1133,8 +1114,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
|
|
|
1133
1114
|
}
|
|
1134
1115
|
|
|
1135
1116
|
return escapeHatchResult(providerRoot, "unjustified-loose-schema", findings, {
|
|
1136
|
-
blockerMessage:
|
|
1137
|
-
"Loose schema (z.record/z.unknown/z.any) used without justification.",
|
|
1117
|
+
blockerMessage: "Loose schema (z.record/z.unknown/z.any) used without justification.",
|
|
1138
1118
|
remediation:
|
|
1139
1119
|
"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.",
|
|
1140
1120
|
passMessage: "Loose schemas are justified or absent.",
|
|
@@ -1163,24 +1143,18 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1163
1143
|
let sawDeclaration = false;
|
|
1164
1144
|
for (const filePath of [
|
|
1165
1145
|
indexPath,
|
|
1166
|
-
...listNonTestTypeScriptFiles(providerRoot).filter(
|
|
1167
|
-
(p) => resolve(p) !== resolve(indexPath),
|
|
1168
|
-
),
|
|
1146
|
+
...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
|
|
1169
1147
|
]) {
|
|
1170
1148
|
if (!existsSync(filePath)) {
|
|
1171
1149
|
continue;
|
|
1172
1150
|
}
|
|
1173
|
-
const fileSource =
|
|
1174
|
-
filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1151
|
+
const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1175
1152
|
const re = new RegExp(declRe.source, "g");
|
|
1176
1153
|
for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
|
|
1177
1154
|
sawDeclaration = true;
|
|
1178
|
-
const expr = unwrapParens(
|
|
1179
|
-
balancedValueExpression(fileSource, m.index + m[0].length).trim(),
|
|
1180
|
-
);
|
|
1155
|
+
const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
|
|
1181
1156
|
const isFactory =
|
|
1182
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
|
|
1183
|
-
hasTopLevelFactorySpread(expr)) &&
|
|
1157
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1184
1158
|
!isTransparentObjectReshape(expr);
|
|
1185
1159
|
if (isFactory) {
|
|
1186
1160
|
return true;
|
|
@@ -1232,9 +1206,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1232
1206
|
if (inlineDefault) {
|
|
1233
1207
|
defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
|
|
1234
1208
|
} else {
|
|
1235
|
-
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(
|
|
1236
|
-
source,
|
|
1237
|
-
);
|
|
1209
|
+
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
|
|
1238
1210
|
const exportedName = namedDefault?.[1];
|
|
1239
1211
|
if (exportedName !== undefined) {
|
|
1240
1212
|
const namedDecl = new RegExp(
|
|
@@ -1308,9 +1280,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1308
1280
|
// Search index.ts first (its line attribution wins), then siblings.
|
|
1309
1281
|
const searchOrder = [
|
|
1310
1282
|
indexPath,
|
|
1311
|
-
...listNonTestTypeScriptFiles(providerRoot).filter(
|
|
1312
|
-
(p) => resolve(p) !== resolve(indexPath),
|
|
1313
|
-
),
|
|
1283
|
+
...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
|
|
1314
1284
|
];
|
|
1315
1285
|
|
|
1316
1286
|
// Collect EVERY same-named declaration across the submission and classify
|
|
@@ -1330,23 +1300,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1330
1300
|
if (!existsSync(filePath)) {
|
|
1331
1301
|
continue;
|
|
1332
1302
|
}
|
|
1333
|
-
const fileSource =
|
|
1334
|
-
filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1303
|
+
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1335
1304
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1336
1305
|
|
|
1337
1306
|
const declRe = new RegExp(aliasDecl.source, "g");
|
|
1338
|
-
for (
|
|
1339
|
-
let m = declRe.exec(fileSource);
|
|
1340
|
-
m !== null;
|
|
1341
|
-
m = declRe.exec(fileSource)
|
|
1342
|
-
) {
|
|
1307
|
+
for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
|
|
1343
1308
|
const valueStart = m.index + m[0].length;
|
|
1344
|
-
const expr = unwrapParens(
|
|
1345
|
-
balancedValueExpression(fileSource, valueStart).trim(),
|
|
1346
|
-
);
|
|
1309
|
+
const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
|
|
1347
1310
|
const isFactory =
|
|
1348
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
|
|
1349
|
-
hasTopLevelFactorySpread(expr)) &&
|
|
1311
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1350
1312
|
!isTransparentObjectReshape(expr);
|
|
1351
1313
|
candidates.push({
|
|
1352
1314
|
expr,
|
|
@@ -1356,11 +1318,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1356
1318
|
});
|
|
1357
1319
|
}
|
|
1358
1320
|
const destructRe = new RegExp(destructured.source, "g");
|
|
1359
|
-
for (
|
|
1360
|
-
let m = destructRe.exec(fileSource);
|
|
1361
|
-
m !== null;
|
|
1362
|
-
m = destructRe.exec(fileSource)
|
|
1363
|
-
) {
|
|
1321
|
+
for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
|
|
1364
1322
|
candidates.push({
|
|
1365
1323
|
expr: `${m[1]}(`,
|
|
1366
1324
|
line: offsetToLine(fileSource, m.index),
|
|
@@ -1388,9 +1346,9 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1388
1346
|
// the unresolved import as a factory-composed (non-static) shape rather
|
|
1389
1347
|
// than silently passing.
|
|
1390
1348
|
if (!resolved) {
|
|
1391
|
-
const importMatch = new RegExp(
|
|
1392
|
-
|
|
1393
|
-
)
|
|
1349
|
+
const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
|
|
1350
|
+
source,
|
|
1351
|
+
);
|
|
1394
1352
|
if (importMatch) {
|
|
1395
1353
|
effective = `${aliasName}(`;
|
|
1396
1354
|
effectiveLine = offsetToLine(source, importMatch.index);
|
|
@@ -1405,8 +1363,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1405
1363
|
// inspect depth-1 entries so that ordinary spreads deep inside operation
|
|
1406
1364
|
// handler bodies (e.g. `{ ...headers }`, `...arr.map(...)`) are NOT mistaken
|
|
1407
1365
|
// for a top-level factory composition of the operations map itself.
|
|
1408
|
-
const hasFactorySpread =
|
|
1409
|
-
effective !== undefined && hasTopLevelFactorySpread(effective);
|
|
1366
|
+
const hasFactorySpread = effective !== undefined && hasTopLevelFactorySpread(effective);
|
|
1410
1367
|
// A spread of a bare identifier (`{ ...hidden }`) is static ONLY when that
|
|
1411
1368
|
// identifier resolves to a non-factory declaration. Resolve each top-level
|
|
1412
1369
|
// spread identifier so an opaque factory map laundered through a variable
|
|
@@ -1417,18 +1374,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1417
1374
|
spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
|
|
1418
1375
|
);
|
|
1419
1376
|
const isStaticLiteral =
|
|
1420
|
-
effective?.startsWith("{") === true &&
|
|
1421
|
-
!hasFactorySpread &&
|
|
1422
|
-
!hasFactorySpreadIdentifier;
|
|
1377
|
+
effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
1423
1378
|
// A call expression `ident(...)` (factory) or a factory-spread literal is
|
|
1424
1379
|
// the rejected, non-static shape — UNLESS it is the stdlib
|
|
1425
1380
|
// `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
|
|
1426
1381
|
// whose op set is still enumerable from source (verified golden pattern).
|
|
1427
1382
|
const isFactoryCall =
|
|
1428
1383
|
effective !== undefined &&
|
|
1429
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
|
|
1430
|
-
hasFactorySpread ||
|
|
1431
|
-
hasFactorySpreadIdentifier) &&
|
|
1384
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
|
|
1432
1385
|
!isTransparentObjectReshape(effective);
|
|
1433
1386
|
|
|
1434
1387
|
if (isFactoryCall && !isStaticLiteral) {
|
|
@@ -1436,19 +1389,13 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1436
1389
|
// `// @apifuse-allow flat-operation-composition: <reason>` comment on
|
|
1437
1390
|
// the reported line (or the line above) downgrades this blocker to a
|
|
1438
1391
|
// counted warning, consistent with the other structural rules.
|
|
1439
|
-
return escapeHatchResult(
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
remediation:
|
|
1447
|
-
"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>`.",
|
|
1448
|
-
passMessage:
|
|
1449
|
-
"defineProvider declares operations as a static object literal.",
|
|
1450
|
-
},
|
|
1451
|
-
);
|
|
1392
|
+
return escapeHatchResult(providerRoot, ruleId, [{ file: effectiveFile, line: effectiveLine }], {
|
|
1393
|
+
blockerMessage:
|
|
1394
|
+
"defineProvider operations are composed by a factory call instead of a static object literal.",
|
|
1395
|
+
remediation:
|
|
1396
|
+
"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>`.",
|
|
1397
|
+
passMessage: "defineProvider declares operations as a static object literal.",
|
|
1398
|
+
});
|
|
1452
1399
|
}
|
|
1453
1400
|
|
|
1454
1401
|
return pass(
|
|
@@ -1459,18 +1406,11 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1459
1406
|
);
|
|
1460
1407
|
}
|
|
1461
1408
|
|
|
1462
|
-
function scoreCredentialUsage(
|
|
1463
|
-
providerRoot
|
|
1464
|
-
provider: ProviderDefinition,
|
|
1465
|
-
): SubmitCheck {
|
|
1466
|
-
const credentialReferences = findSourceLineMatches(
|
|
1467
|
-
providerRoot,
|
|
1468
|
-
/ctx\.credential/,
|
|
1469
|
-
);
|
|
1409
|
+
function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1410
|
+
const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
|
|
1470
1411
|
const authMode = provider.auth?.mode ?? "none";
|
|
1471
1412
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
1472
|
-
const storesProviderCredential =
|
|
1473
|
-
authMode !== "none" || credentialKeys.length > 0;
|
|
1413
|
+
const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
|
|
1474
1414
|
|
|
1475
1415
|
if (storesProviderCredential && credentialReferences.length === 0) {
|
|
1476
1416
|
return {
|
|
@@ -1480,8 +1420,7 @@ function scoreCredentialUsage(
|
|
|
1480
1420
|
status: "warn",
|
|
1481
1421
|
points: 0,
|
|
1482
1422
|
maxPoints: 0,
|
|
1483
|
-
message:
|
|
1484
|
-
"Credential-backed provider does not reference credential persistence in source.",
|
|
1423
|
+
message: "Credential-backed provider does not reference credential persistence in source.",
|
|
1485
1424
|
remediation:
|
|
1486
1425
|
"Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
|
|
1487
1426
|
};
|
|
@@ -1494,19 +1433,168 @@ function scoreCredentialUsage(
|
|
|
1494
1433
|
? "Provider does not declare reusable credentials."
|
|
1495
1434
|
: "Credential-backed provider references ctx.credential.",
|
|
1496
1435
|
0,
|
|
1497
|
-
credentialReferences.length > 0
|
|
1498
|
-
|
|
1499
|
-
|
|
1436
|
+
credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ---------------------------------------------------------------------------
|
|
1441
|
+
// sdk-owned-secret-presence (warn): provider-local double validation of
|
|
1442
|
+
// declared env secrets.
|
|
1443
|
+
//
|
|
1444
|
+
// The SDK runtime is the single source of truth for secret presence: declared
|
|
1445
|
+
// `required: true` secrets are validated before every handler/auth-flow
|
|
1446
|
+
// invocation and fail with the canonical structured MISSING_SECRET error
|
|
1447
|
+
// (HTTP 400, category credential_unavailable). Provider-local presence guards
|
|
1448
|
+
// (requireServiceKey/requireApiKey style) are dead weight that historically
|
|
1449
|
+
// diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
|
|
1450
|
+
// with/without category), which broke uniform incident attribution when nine
|
|
1451
|
+
// providers shipped with unprovisioned secrets (2026-07-22).
|
|
1452
|
+
//
|
|
1453
|
+
// Heuristic, warn-only: a line reading a declared `required: true` secret via
|
|
1454
|
+
// `.env.get(...)` (string literal or a const alias of a declared name)
|
|
1455
|
+
// followed within a small window by a falsy presence check plus a `throw`.
|
|
1456
|
+
// The rule flags duplication of the SDK gate ONLY: env names that are not
|
|
1457
|
+
// declared in defineProvider secrets[], and optional declarations
|
|
1458
|
+
// (`required: false`/omitted) that the runtime deliberately does not enforce,
|
|
1459
|
+
// are out of scope. Escape hatch:
|
|
1460
|
+
// `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
|
|
1461
|
+
// ---------------------------------------------------------------------------
|
|
1462
|
+
|
|
1463
|
+
const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
|
|
1464
|
+
const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
|
|
1465
|
+
|
|
1466
|
+
const ENV_GET_CALL_PATTERN =
|
|
1467
|
+
/\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
|
|
1468
|
+
|
|
1469
|
+
const SECRET_ALIAS_CONST_PATTERN =
|
|
1470
|
+
/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
|
|
1471
|
+
|
|
1472
|
+
const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
|
|
1473
|
+
|
|
1474
|
+
// Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
|
|
1475
|
+
// "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
|
|
1476
|
+
// guards are detected, not just direct string literals.
|
|
1477
|
+
function buildDeclaredSecretAliasMap(
|
|
1478
|
+
providerRoot: string,
|
|
1479
|
+
declaredNames: ReadonlySet<string>,
|
|
1480
|
+
): Map<string, string> {
|
|
1481
|
+
const aliases = new Map<string, string>();
|
|
1482
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
1483
|
+
const content = readFileSync(filePath, "utf8");
|
|
1484
|
+
for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
|
|
1485
|
+
const alias = match[1];
|
|
1486
|
+
const name = match[2] ?? match[3] ?? match[4];
|
|
1487
|
+
if (alias && name && declaredNames.has(name)) {
|
|
1488
|
+
aliases.set(alias, name);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return aliases;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function hasLocalSecretPresenceGuard(
|
|
1496
|
+
line: string,
|
|
1497
|
+
remainingLines: readonly string[],
|
|
1498
|
+
declaredNames: ReadonlySet<string>,
|
|
1499
|
+
aliases: ReadonlyMap<string, string>,
|
|
1500
|
+
): boolean {
|
|
1501
|
+
const match = ENV_GET_CALL_PATTERN.exec(line);
|
|
1502
|
+
if (!match) {
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
const literal = match[1] ?? match[2] ?? match[3];
|
|
1506
|
+
const identifier = match[4];
|
|
1507
|
+
const readsDeclaredSecret =
|
|
1508
|
+
literal !== undefined
|
|
1509
|
+
? declaredNames.has(literal)
|
|
1510
|
+
: identifier !== undefined && aliases.has(identifier);
|
|
1511
|
+
if (!readsDeclaredSecret) {
|
|
1512
|
+
return false;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
|
|
1516
|
+
if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
|
|
1521
|
+
// check on THAT variable counts as a presence guard. Anchoring on the
|
|
1522
|
+
// assigned identifier avoids false positives from unrelated guards/throws
|
|
1523
|
+
// that merely sit near the env read (mirrors the aliased runtime-guard rule).
|
|
1524
|
+
const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
|
|
1525
|
+
if (assigned) {
|
|
1526
|
+
const escaped = assigned.replace(/\$/g, "\\$");
|
|
1527
|
+
const guardPattern = new RegExp(
|
|
1528
|
+
`(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
|
|
1529
|
+
);
|
|
1530
|
+
return window.some((candidate) => guardPattern.test(candidate));
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Un-assigned read: only an inline presence check on the same line counts,
|
|
1534
|
+
// e.g. `if (!ctx.env.get(KEY)) throw ...`.
|
|
1535
|
+
return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function scoreSdkOwnedSecretPresence(
|
|
1539
|
+
providerRoot: string,
|
|
1540
|
+
provider: ProviderDefinition,
|
|
1541
|
+
): SubmitCheck {
|
|
1542
|
+
const passMessage = "Provider relies on SDK-owned secret presence validation.";
|
|
1543
|
+
// Only `required: true` declarations: those are exactly what the runtime
|
|
1544
|
+
// gate enforces. A presence guard over an optional secret is conditional
|
|
1545
|
+
// business logic the SDK will not replace, not double validation.
|
|
1546
|
+
const declaredNames: ReadonlySet<string> = new Set(
|
|
1547
|
+
(provider.secrets ?? [])
|
|
1548
|
+
.filter((secret) => secret.required === true)
|
|
1549
|
+
.map((secret) => secret.name),
|
|
1550
|
+
);
|
|
1551
|
+
if (declaredNames.size === 0) {
|
|
1552
|
+
return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
|
|
1556
|
+
const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
|
|
1557
|
+
hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
|
|
1558
|
+
);
|
|
1559
|
+
if (findings.length === 0) {
|
|
1560
|
+
return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const { violations, overridden } = partitionAllowOverrides(
|
|
1564
|
+
providerRoot,
|
|
1565
|
+
findings,
|
|
1566
|
+
SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
1500
1567
|
);
|
|
1568
|
+
if (violations.length === 0) {
|
|
1569
|
+
return pass(
|
|
1570
|
+
SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
1571
|
+
SDK_NATIVE_CATEGORY,
|
|
1572
|
+
`${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
|
|
1573
|
+
0,
|
|
1574
|
+
formatSourceFindings(overridden),
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
return {
|
|
1579
|
+
id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
1580
|
+
category: SDK_NATIVE_CATEGORY,
|
|
1581
|
+
level: "warn",
|
|
1582
|
+
status: "warn",
|
|
1583
|
+
points: 0,
|
|
1584
|
+
maxPoints: 0,
|
|
1585
|
+
message:
|
|
1586
|
+
"Provider source re-validates declared env secret presence locally; the SDK owns this check.",
|
|
1587
|
+
remediation:
|
|
1588
|
+
"The provider SDK validates declared required secrets before handlers and auth flows run and returns the canonical MISSING_SECRET error (HTTP 400, category credential_unavailable). Declare the secret with required: true in defineProvider({ secrets: [...] }), delete the provider-local presence guard (requireServiceKey/requireApiKey style), and read the value directly with ctx.env.get(); the guard is dead weight and its divergent CONFIGURATION_ERROR-style shape is deprecated. Acknowledge intentional exceptions with `// @apifuse-allow sdk-owned-secret-presence: <reason>`.",
|
|
1589
|
+
evidence: formatSourceFindings(violations),
|
|
1590
|
+
};
|
|
1501
1591
|
}
|
|
1502
1592
|
|
|
1503
1593
|
function findSourceLineMatches(
|
|
1504
1594
|
providerRoot: string,
|
|
1505
1595
|
pattern: RegExp | ((line: string) => boolean),
|
|
1506
1596
|
): SourceFinding[] {
|
|
1507
|
-
return findSourceFindings(providerRoot, (line) =>
|
|
1508
|
-
matchesLinePattern(line, pattern),
|
|
1509
|
-
);
|
|
1597
|
+
return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
|
|
1510
1598
|
}
|
|
1511
1599
|
|
|
1512
1600
|
function findSourceFindings(
|
|
@@ -1533,10 +1621,7 @@ function findSourceFindings(
|
|
|
1533
1621
|
return findings;
|
|
1534
1622
|
}
|
|
1535
1623
|
|
|
1536
|
-
function matchesLinePattern(
|
|
1537
|
-
line: string,
|
|
1538
|
-
pattern: RegExp | ((line: string) => boolean),
|
|
1539
|
-
): boolean {
|
|
1624
|
+
function matchesLinePattern(line: string, pattern: RegExp | ((line: string) => boolean)): boolean {
|
|
1540
1625
|
return typeof pattern === "function" ? pattern(line) : pattern.test(line);
|
|
1541
1626
|
}
|
|
1542
1627
|
|
|
@@ -1590,11 +1675,7 @@ function collectNonTestTypeScriptFiles(
|
|
|
1590
1675
|
}
|
|
1591
1676
|
continue;
|
|
1592
1677
|
}
|
|
1593
|
-
if (
|
|
1594
|
-
entry.isFile() &&
|
|
1595
|
-
relativePath.endsWith(".ts") &&
|
|
1596
|
-
!isExcludedTestSource(relativePath)
|
|
1597
|
-
) {
|
|
1678
|
+
if (entry.isFile() && relativePath.endsWith(".ts") && !isExcludedTestSource(relativePath)) {
|
|
1598
1679
|
files.push(entryPath);
|
|
1599
1680
|
}
|
|
1600
1681
|
}
|
|
@@ -1608,10 +1689,12 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
|
|
|
1608
1689
|
);
|
|
1609
1690
|
}
|
|
1610
1691
|
|
|
1692
|
+
// `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
|
|
1693
|
+
// markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
|
|
1694
|
+
// directories must not become a scan-exempt hiding place for secrets, raw
|
|
1695
|
+
// fetch, or vendor imports.
|
|
1611
1696
|
function shouldScanSourceDirectory(relativePath: string): boolean {
|
|
1612
|
-
return ![".git", "node_modules", "dist", "build", "coverage"].includes(
|
|
1613
|
-
relativePath,
|
|
1614
|
-
);
|
|
1697
|
+
return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
|
|
1615
1698
|
}
|
|
1616
1699
|
|
|
1617
1700
|
function isExcludedTestSource(relativePath: string): boolean {
|
|
@@ -1624,10 +1707,7 @@ function isExcludedTestSource(relativePath: string): boolean {
|
|
|
1624
1707
|
);
|
|
1625
1708
|
}
|
|
1626
1709
|
|
|
1627
|
-
function toRelativeProviderPath(
|
|
1628
|
-
providerRoot: string,
|
|
1629
|
-
filePath: string,
|
|
1630
|
-
): string {
|
|
1710
|
+
function toRelativeProviderPath(providerRoot: string, filePath: string): string {
|
|
1631
1711
|
return relative(providerRoot, filePath).replaceAll("\\", "/");
|
|
1632
1712
|
}
|
|
1633
1713
|
|
|
@@ -1635,11 +1715,35 @@ function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
|
|
|
1635
1715
|
return findings.map((finding) => `${finding.file}:${finding.line}`);
|
|
1636
1716
|
}
|
|
1637
1717
|
|
|
1718
|
+
function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
|
|
1719
|
+
const verification = verifyPromptAssets(providerRoot);
|
|
1720
|
+
if (verification.ok) {
|
|
1721
|
+
return pass(
|
|
1722
|
+
"prompt-assets-fresh",
|
|
1723
|
+
"docs",
|
|
1724
|
+
"SDK-managed agent prompt assets match the installed SDK version.",
|
|
1725
|
+
0,
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
return blocker(
|
|
1730
|
+
"prompt-assets-fresh",
|
|
1731
|
+
"docs",
|
|
1732
|
+
"SDK-managed agent prompt assets are missing, stale, or modified.",
|
|
1733
|
+
"Run `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate AGENTS.md, .agents/skills/**, the CLAUDE.md/.claude/.codex symlinks, and .apifuse/prompt-assets.json for the installed SDK version.",
|
|
1734
|
+
0,
|
|
1735
|
+
formatPromptAssetIssues(verification),
|
|
1736
|
+
);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1638
1739
|
function scoreRepositoryDx(providerRoot: string): SubmitCheck {
|
|
1639
1740
|
const missing: string[] = [];
|
|
1640
1741
|
if (!existsSync(resolve(providerRoot, ".gitignore"))) {
|
|
1641
1742
|
missing.push(".gitignore");
|
|
1642
1743
|
}
|
|
1744
|
+
if (!existsSync(resolve(providerRoot, "AGENTS.md"))) {
|
|
1745
|
+
missing.push("AGENTS.md");
|
|
1746
|
+
}
|
|
1643
1747
|
|
|
1644
1748
|
const packageJsonPath = resolve(providerRoot, "package.json");
|
|
1645
1749
|
const packageScripts = readPackageScripts(packageJsonPath);
|
|
@@ -1668,14 +1772,12 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
|
|
|
1668
1772
|
maxPoints: 0,
|
|
1669
1773
|
message: `Generated repository DX guardrails are missing: ${missing.join(", ")}.`,
|
|
1670
1774
|
remediation:
|
|
1671
|
-
"Regenerate with the current `apifuse create` template or
|
|
1775
|
+
"Regenerate with the current `apifuse create` template or restore the missing files: .gitignore, AGENTS.md (agent contribution guide), plus `type-check: tsc --noEmit` included from `check`.",
|
|
1672
1776
|
evidence: missing,
|
|
1673
1777
|
};
|
|
1674
1778
|
}
|
|
1675
1779
|
|
|
1676
|
-
function readPackageScripts(
|
|
1677
|
-
packageJsonPath: string,
|
|
1678
|
-
): Record<string, unknown> | undefined {
|
|
1780
|
+
function readPackageScripts(packageJsonPath: string): Record<string, unknown> | undefined {
|
|
1679
1781
|
if (!existsSync(packageJsonPath)) {
|
|
1680
1782
|
return undefined;
|
|
1681
1783
|
}
|
|
@@ -1790,6 +1892,10 @@ function scoreManagedBrowserRuntime(providerRoot: string): SubmitCheck {
|
|
|
1790
1892
|
function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
|
|
1791
1893
|
const failed = results.filter((result) => !result.passed);
|
|
1792
1894
|
if (failed.length > 0) {
|
|
1895
|
+
const remediation = [
|
|
1896
|
+
"Run `bunx apifuse check .` from the provider root.",
|
|
1897
|
+
...Array.from(new Set(failed.map(baseCheckRemediation))),
|
|
1898
|
+
].join(" ");
|
|
1793
1899
|
return [
|
|
1794
1900
|
{
|
|
1795
1901
|
id: "base-checks",
|
|
@@ -1799,8 +1905,7 @@ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
|
|
|
1799
1905
|
points: 0,
|
|
1800
1906
|
maxPoints: CATEGORY_MAX_POINTS.definition,
|
|
1801
1907
|
message: "Base provider checks failed.",
|
|
1802
|
-
remediation
|
|
1803
|
-
"Run `bun run check` and fix every failing item before bounty submission.",
|
|
1908
|
+
remediation,
|
|
1804
1909
|
evidence: failed.map((result) =>
|
|
1805
1910
|
redact(`${result.message}: ${(result.details ?? []).join("; ")}`),
|
|
1806
1911
|
),
|
|
@@ -1822,10 +1927,32 @@ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
|
|
|
1822
1927
|
];
|
|
1823
1928
|
}
|
|
1824
1929
|
|
|
1825
|
-
function
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1930
|
+
function baseCheckRemediation(result: CheckResult): string {
|
|
1931
|
+
switch (result.message) {
|
|
1932
|
+
case "index.ts exists and exports default defineProvider":
|
|
1933
|
+
return "Fix `index.ts` so it default-exports `defineProvider({...})`.";
|
|
1934
|
+
case "All operations have handler, input, output":
|
|
1935
|
+
return "For each operation named in evidence, add `handler`, `input`, and `output` fields to `defineProvider({ operations })`.";
|
|
1936
|
+
case "All operations have fixtures":
|
|
1937
|
+
return "For each operation named in evidence, add `fixtures.request` and `fixtures.response` values that exercise the operation schemas.";
|
|
1938
|
+
case "Zod schemas parse fixtures without error":
|
|
1939
|
+
return "Update the failing fixture values or their zod schemas until `fixtures.request` and `fixtures.response` parse cleanly.";
|
|
1940
|
+
case "Provider authoring lint has no error-level diagnostics":
|
|
1941
|
+
return "Fix each lint diagnostic shown in evidence, then rerun `bunx apifuse check .`.";
|
|
1942
|
+
case "Provider metadata is declared in defineProvider":
|
|
1943
|
+
return "Fill the missing `defineProvider` metadata fields: `id`, `meta.displayName`, `meta.category`, `runtime`, and `auth.mode`.";
|
|
1944
|
+
case "Dockerfile exists":
|
|
1945
|
+
return "Add a provider-root `Dockerfile` based on the current `apifuse create` template.";
|
|
1946
|
+
case "package.json exists with @apifuse/provider-sdk dependency":
|
|
1947
|
+
return "Add `@apifuse/provider-sdk` to `package.json` dependencies.";
|
|
1948
|
+
case "Base provider checks can run":
|
|
1949
|
+
return "Fix the import/runtime error shown in evidence so `apifuse check` can load the provider.";
|
|
1950
|
+
default:
|
|
1951
|
+
return `Fix the failing base check "${result.message}" shown in evidence.`;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
function scoreLocaleCatalog(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1829
1956
|
const requiredKeys = collectProviderRequiredLocaleKeys(provider);
|
|
1830
1957
|
if (requiredKeys.length === 0) {
|
|
1831
1958
|
return pass(
|
|
@@ -1856,9 +1983,7 @@ function scoreLocaleCatalog(
|
|
|
1856
1983
|
"Provider locale catalog is missing required public-provider copy.",
|
|
1857
1984
|
"Add provider-local locales/en.json and locales/ko.json values for every provider metadata key, operation descriptionKey, and .describeKey() or describeKey() schema field.",
|
|
1858
1985
|
0,
|
|
1859
|
-
validation.issues.map(
|
|
1860
|
-
(issue) => `${issue.locale}:${issue.key}: ${issue.message}`,
|
|
1861
|
-
),
|
|
1986
|
+
validation.issues.map((issue) => `${issue.locale}:${issue.key}: ${issue.message}`),
|
|
1862
1987
|
);
|
|
1863
1988
|
}
|
|
1864
1989
|
} catch (error) {
|
|
@@ -1881,9 +2006,7 @@ function scoreLocaleCatalog(
|
|
|
1881
2006
|
);
|
|
1882
2007
|
}
|
|
1883
2008
|
|
|
1884
|
-
function collectProviderRequiredLocaleKeys(
|
|
1885
|
-
provider: ProviderDefinition,
|
|
1886
|
-
): string[] {
|
|
2009
|
+
function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string[] {
|
|
1887
2010
|
const keys = new Set<string>();
|
|
1888
2011
|
|
|
1889
2012
|
addLocaleKeys(keys, [
|
|
@@ -1946,10 +2069,7 @@ function collectSchemaDescriptionKeys(schema: unknown): string[] {
|
|
|
1946
2069
|
return keys;
|
|
1947
2070
|
}
|
|
1948
2071
|
|
|
1949
|
-
function collectJsonSchemaDescriptionKeys(
|
|
1950
|
-
schema: Record<string, unknown>,
|
|
1951
|
-
keys: string[],
|
|
1952
|
-
): void {
|
|
2072
|
+
function collectJsonSchemaDescriptionKeys(schema: Record<string, unknown>, keys: string[]): void {
|
|
1953
2073
|
const descriptionKey = schema[APIFUSE_DESCRIPTION_KEY_META_KEY];
|
|
1954
2074
|
if (typeof descriptionKey === "string" && descriptionKey.length > 0) {
|
|
1955
2075
|
keys.push(descriptionKey);
|
|
@@ -1981,8 +2101,7 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
1981
2101
|
// is enforced at registry catalog-build time, matching how lintOperation
|
|
1982
2102
|
// skips the raw-description min-length rule when a descriptionKey is set.
|
|
1983
2103
|
const hasDescriptionKey =
|
|
1984
|
-
typeof operation.descriptionKey === "string" &&
|
|
1985
|
-
operation.descriptionKey.length > 0;
|
|
2104
|
+
typeof operation.descriptionKey === "string" && operation.descriptionKey.length > 0;
|
|
1986
2105
|
if (hasDescriptionKey) return false;
|
|
1987
2106
|
return true;
|
|
1988
2107
|
})
|
|
@@ -2000,14 +2119,12 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2000
2119
|
points: 0,
|
|
2001
2120
|
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
2002
2121
|
message: "One or more operations have weak descriptions.",
|
|
2003
|
-
remediation:
|
|
2004
|
-
"Add 150+ character English descriptions explaining when to use, when not to use, outputs, and caveats.",
|
|
2122
|
+
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.`,
|
|
2005
2123
|
evidence: weakDescriptions,
|
|
2006
2124
|
};
|
|
2007
2125
|
}
|
|
2008
2126
|
|
|
2009
|
-
const points =
|
|
2010
|
-
missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
2127
|
+
const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
2011
2128
|
return {
|
|
2012
2129
|
id: "operation-metadata",
|
|
2013
2130
|
category: "operations",
|
|
@@ -2021,30 +2138,25 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2021
2138
|
: "Operation descriptions and metadata are review-ready.",
|
|
2022
2139
|
remediation:
|
|
2023
2140
|
missingAnnotations.length > 0
|
|
2024
|
-
? "
|
|
2141
|
+
? `For ${missingAnnotations.join(", ")}, add \`annotations\` with the applicable safety fields, such as \`readOnly\`, \`destructive\`, \`idempotent\`, \`openWorld\`, \`rateLimit\`, or \`timeoutMs\`.`
|
|
2025
2142
|
: undefined,
|
|
2026
2143
|
evidence:
|
|
2027
2144
|
missingAnnotations.length > 0
|
|
2028
|
-
? missingAnnotations.map(
|
|
2029
|
-
(operationId) => `${operationId}: missing annotations`,
|
|
2030
|
-
)
|
|
2145
|
+
? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
|
|
2031
2146
|
: operations.map(([operationId]) => operationId),
|
|
2032
2147
|
};
|
|
2033
2148
|
}
|
|
2034
2149
|
|
|
2035
2150
|
function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
2036
2151
|
const missing = Object.entries(provider.operations)
|
|
2037
|
-
.filter(
|
|
2038
|
-
([, operation]) =>
|
|
2039
|
-
!operation.fixtures?.request || !operation.fixtures?.response,
|
|
2040
|
-
)
|
|
2152
|
+
.filter(([, operation]) => !operation.fixtures?.request || !operation.fixtures?.response)
|
|
2041
2153
|
.map(([operationId]) => operationId);
|
|
2042
2154
|
if (missing.length > 0) {
|
|
2043
2155
|
return blocker(
|
|
2044
2156
|
"fixtures",
|
|
2045
2157
|
"fixtures",
|
|
2046
2158
|
"One or more operations are missing bidirectional fixtures.",
|
|
2047
|
-
"
|
|
2159
|
+
`For ${missing.join(", ")}, add \`fixtures.request\` and \`fixtures.response\` values that parse against the operation input and output schemas.`,
|
|
2048
2160
|
CATEGORY_MAX_POINTS.fixtures,
|
|
2049
2161
|
missing,
|
|
2050
2162
|
);
|
|
@@ -2057,97 +2169,800 @@ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2057
2169
|
);
|
|
2058
2170
|
}
|
|
2059
2171
|
|
|
2060
|
-
|
|
2061
|
-
const operations = Object.entries(provider.operations);
|
|
2062
|
-
const missing: string[] = [];
|
|
2063
|
-
const placeholder: string[] = [];
|
|
2064
|
-
const unsupported: string[] = [];
|
|
2065
|
-
const generatedStarter: string[] = [];
|
|
2172
|
+
const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
|
|
2066
2173
|
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
const reason = operation.healthCheckUnsupported?.reason ?? "";
|
|
2076
|
-
unsupported.push(operationId);
|
|
2077
|
-
if (/generated local-only scaffold/i.test(reason)) {
|
|
2078
|
-
generatedStarter.push(operationId);
|
|
2079
|
-
}
|
|
2080
|
-
if (
|
|
2081
|
-
/(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(
|
|
2082
|
-
reason,
|
|
2083
|
-
)
|
|
2084
|
-
) {
|
|
2085
|
-
placeholder.push(operationId);
|
|
2086
|
-
}
|
|
2174
|
+
function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
2175
|
+
const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
|
|
2176
|
+
let hasRecordedEvidence = false;
|
|
2177
|
+
if (existsSync(rawPath)) {
|
|
2178
|
+
try {
|
|
2179
|
+
hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
|
|
2180
|
+
} catch {
|
|
2181
|
+
hasRecordedEvidence = false;
|
|
2087
2182
|
}
|
|
2088
2183
|
}
|
|
2089
2184
|
|
|
2090
|
-
if (
|
|
2091
|
-
return
|
|
2092
|
-
"
|
|
2093
|
-
"
|
|
2094
|
-
"
|
|
2095
|
-
|
|
2096
|
-
CATEGORY_MAX_POINTS.health,
|
|
2097
|
-
missing,
|
|
2185
|
+
if (hasRecordedEvidence) {
|
|
2186
|
+
return pass(
|
|
2187
|
+
"fixture-provenance",
|
|
2188
|
+
"fixtures",
|
|
2189
|
+
"Recorded upstream fixture evidence is present.",
|
|
2190
|
+
0,
|
|
2098
2191
|
);
|
|
2099
2192
|
}
|
|
2100
2193
|
|
|
2101
|
-
if (
|
|
2194
|
+
if (allOperationsAreGeneratedLocalScaffold(provider)) {
|
|
2102
2195
|
return {
|
|
2103
|
-
id: "
|
|
2104
|
-
category: "
|
|
2196
|
+
id: "fixture-provenance",
|
|
2197
|
+
category: "fixtures",
|
|
2105
2198
|
level: "warn",
|
|
2106
2199
|
status: "warn",
|
|
2107
|
-
points:
|
|
2108
|
-
maxPoints:
|
|
2109
|
-
message:
|
|
2200
|
+
points: 0,
|
|
2201
|
+
maxPoints: 0,
|
|
2202
|
+
message:
|
|
2203
|
+
"Generated local-only scaffold has no recorded upstream fixture evidence yet; run `bun run record` once real operations exist.",
|
|
2110
2204
|
remediation:
|
|
2111
|
-
"
|
|
2112
|
-
evidence:
|
|
2205
|
+
"Run `bun run record` (apifuse record) against the real upstream to capture raw payloads once real operations exist.",
|
|
2206
|
+
evidence: ["__fixtures__/raw.json"],
|
|
2113
2207
|
};
|
|
2114
2208
|
}
|
|
2115
2209
|
|
|
2116
|
-
|
|
2210
|
+
return blocker(
|
|
2211
|
+
"fixture-provenance",
|
|
2212
|
+
"fixtures",
|
|
2213
|
+
"No recorded upstream fixture evidence (__fixtures__/raw.json is empty or missing).",
|
|
2214
|
+
"Run `bun run record` (apifuse record) against the real upstream to capture actual recorded upstream payloads per operation in __fixtures__/raw.json; derive normalized expectations in tests from mapper(recorded raw). Hand-authored fixtures without recorded provenance are not reviewable.",
|
|
2215
|
+
0,
|
|
2216
|
+
["__fixtures__/raw.json"],
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
export function hasNonEmptyRecordedFixture(value: unknown): boolean {
|
|
2221
|
+
return recordedFixtureStats(value, 0).hasNestedSubstance;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function recordedFixtureStats(
|
|
2225
|
+
value: unknown,
|
|
2226
|
+
depth: number,
|
|
2227
|
+
): { hasNestedSubstance: boolean; leafValues: number } {
|
|
2228
|
+
if (value === null || value === undefined) {
|
|
2229
|
+
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2230
|
+
}
|
|
2231
|
+
if (Array.isArray(value)) {
|
|
2232
|
+
let leafValues = 0;
|
|
2233
|
+
let hasNestedSubstance = false;
|
|
2234
|
+
for (const item of value) {
|
|
2235
|
+
const child = recordedFixtureStats(item, depth + 1);
|
|
2236
|
+
leafValues += child.leafValues;
|
|
2237
|
+
hasNestedSubstance ||= child.hasNestedSubstance;
|
|
2238
|
+
}
|
|
2117
2239
|
return {
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
level: "warn",
|
|
2121
|
-
status: "warn",
|
|
2122
|
-
points: 10,
|
|
2123
|
-
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2124
|
-
message:
|
|
2125
|
-
"Generated starter operation health rationale is present; replace starter logic before bounty submission.",
|
|
2126
|
-
remediation:
|
|
2127
|
-
"Replace `ping` with real upstream-backed operations and prefer real healthCheck for safe read-only probes.",
|
|
2128
|
-
evidence: generatedStarter,
|
|
2240
|
+
hasNestedSubstance: hasNestedSubstance || (depth >= 1 && value.length > 0 && leafValues >= 2),
|
|
2241
|
+
leafValues,
|
|
2129
2242
|
};
|
|
2130
2243
|
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2244
|
+
if (typeof value === "object") {
|
|
2245
|
+
let leafValues = 0;
|
|
2246
|
+
let hasNestedSubstance = false;
|
|
2247
|
+
for (const item of Object.values(value)) {
|
|
2248
|
+
const child = recordedFixtureStats(item, depth + 1);
|
|
2249
|
+
leafValues += child.leafValues;
|
|
2250
|
+
hasNestedSubstance ||= child.hasNestedSubstance;
|
|
2251
|
+
}
|
|
2133
2252
|
return {
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2253
|
+
hasNestedSubstance:
|
|
2254
|
+
hasNestedSubstance || (depth >= 1 && Object.keys(value).length > 0 && leafValues >= 2),
|
|
2255
|
+
leafValues,
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
if (typeof value === "string") {
|
|
2259
|
+
if (value.length === 0) {
|
|
2260
|
+
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2261
|
+
}
|
|
2262
|
+
// A recorded operation value may be a raw XML or delimited-text success
|
|
2263
|
+
// payload. Treat a substantive one as nested evidence while still counting
|
|
2264
|
+
// the string as a leaf so existing JSON provenance heuristics are unchanged.
|
|
2265
|
+
return {
|
|
2266
|
+
hasNestedSubstance:
|
|
2267
|
+
hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
|
|
2268
|
+
leafValues: 1,
|
|
2147
2269
|
};
|
|
2148
2270
|
}
|
|
2271
|
+
return { hasNestedSubstance: false, leafValues: 1 };
|
|
2272
|
+
}
|
|
2149
2273
|
|
|
2150
|
-
|
|
2274
|
+
function allOperationsAreGeneratedLocalScaffold(provider: ProviderDefinition): boolean {
|
|
2275
|
+
const operations = Object.values(provider.operations);
|
|
2276
|
+
return (
|
|
2277
|
+
operations.length > 0 &&
|
|
2278
|
+
operations.every((operation) =>
|
|
2279
|
+
GENERATED_LOCAL_ONLY_SCAFFOLD_REASON.test(operation.healthCheckUnsupported?.reason ?? ""),
|
|
2280
|
+
)
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
function scoreVendorKeyLeak(providerRoot: string): SubmitCheck {
|
|
2285
|
+
return escapeHatchResult(
|
|
2286
|
+
providerRoot,
|
|
2287
|
+
"vendor-key-leak",
|
|
2288
|
+
findVendorKeyLeakFindings(providerRoot),
|
|
2289
|
+
{
|
|
2290
|
+
blockerMessage: "Public schema keys leak raw vendor field names.",
|
|
2291
|
+
remediation:
|
|
2292
|
+
"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.",
|
|
2293
|
+
passMessage: "No vendor field-name leaks detected in public schemas.",
|
|
2294
|
+
},
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
function scoreVendorTimestampLeak(providerRoot: string): SubmitCheck {
|
|
2299
|
+
return escapeHatchResult(
|
|
2300
|
+
providerRoot,
|
|
2301
|
+
"vendor-timestamp-leak",
|
|
2302
|
+
findVendorTimestampLeakFindings(providerRoot),
|
|
2303
|
+
{
|
|
2304
|
+
blockerMessage: "Normalized fixtures carry raw vendor timestamp formats.",
|
|
2305
|
+
remediation:
|
|
2306
|
+
"Convert vendor compact timestamps (yyyymmdd, HHmm, yyyymmddHHmmss) to ISO 8601 (date, time with timezone) at the mapper boundary; fixtures.response must show the normalized form. Add `// @apifuse-allow vendor-timestamp-leak` only when the value is genuinely not a timestamp.",
|
|
2307
|
+
passMessage: "No vendor timestamp formats detected in normalized fixtures.",
|
|
2308
|
+
},
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
type ObjectRange = {
|
|
2313
|
+
start: number;
|
|
2314
|
+
end: number;
|
|
2315
|
+
};
|
|
2316
|
+
|
|
2317
|
+
type ZObjectLiteral = {
|
|
2318
|
+
objectStart: number;
|
|
2319
|
+
objectEnd: number;
|
|
2320
|
+
callStart: number;
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
type NamedObjectRange = ObjectRange & {
|
|
2324
|
+
name: string;
|
|
2325
|
+
};
|
|
2326
|
+
|
|
2327
|
+
function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
|
|
2328
|
+
const findings: SourceFinding[] = [];
|
|
2329
|
+
const seen = new Set<string>();
|
|
2330
|
+
|
|
2331
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2332
|
+
const source = readFileSync(filePath, "utf8");
|
|
2333
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2334
|
+
const upstreamRanges = findUpstreamMarkedConstRanges(source);
|
|
2335
|
+
for (const zObject of findZObjectLiterals(source)) {
|
|
2336
|
+
if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
|
|
2337
|
+
continue;
|
|
2338
|
+
}
|
|
2339
|
+
if (!zObjectAppearsPublicOutput(source, zObject)) {
|
|
2340
|
+
continue;
|
|
2341
|
+
}
|
|
2342
|
+
for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
|
|
2343
|
+
const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
|
|
2344
|
+
if (!seen.has(key)) {
|
|
2345
|
+
seen.add(key);
|
|
2346
|
+
findings.push({ file: relPath, line: keyFinding.line });
|
|
2347
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2348
|
+
return findings;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
return findings;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function findZObjectLiterals(source: string): ZObjectLiteral[] {
|
|
2359
|
+
const literals: ZObjectLiteral[] = [];
|
|
2360
|
+
const masked = maskCommentsAndStrings(source);
|
|
2361
|
+
const callPattern = /\bz\s*\.\s*object\s*\(/g;
|
|
2362
|
+
for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
|
|
2363
|
+
const parenIndex = masked.indexOf("(", match.index);
|
|
2364
|
+
const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
|
|
2365
|
+
if (objectStart === -1 || masked[objectStart] !== "{") {
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
const objectEnd = findMatchingBracket(masked, objectStart);
|
|
2369
|
+
if (objectEnd === -1) {
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
literals.push({
|
|
2373
|
+
objectStart,
|
|
2374
|
+
objectEnd,
|
|
2375
|
+
callStart: match.index,
|
|
2376
|
+
});
|
|
2377
|
+
callPattern.lastIndex = objectEnd;
|
|
2378
|
+
}
|
|
2379
|
+
return literals;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
|
|
2383
|
+
const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
|
|
2384
|
+
if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
|
|
2385
|
+
return true;
|
|
2386
|
+
}
|
|
2387
|
+
const before = source.slice(Math.max(0, zObject.callStart - 160), zObject.callStart);
|
|
2388
|
+
return /(?:^|[\s,{])(?:output|response)\s*:\s*$/.test(before);
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
function vendorKeyFindingsForObject(
|
|
2392
|
+
source: string,
|
|
2393
|
+
zObject: ZObjectLiteral,
|
|
2394
|
+
): Array<{ key: string; line: number }> {
|
|
2395
|
+
const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
|
|
2396
|
+
const digitFamilies = new Map<string, Set<string>>();
|
|
2397
|
+
for (const key of keys) {
|
|
2398
|
+
const member = numberedFamilyMember(key.name);
|
|
2399
|
+
if (!member) {
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
const positions = digitFamilies.get(member.base) ?? new Set<string>();
|
|
2403
|
+
positions.add(member.position);
|
|
2404
|
+
digitFamilies.set(member.base, positions);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
return keys
|
|
2408
|
+
.filter((key) => {
|
|
2409
|
+
if (!isAllowedPublicOutputKeyName(key.name)) {
|
|
2410
|
+
return true;
|
|
2411
|
+
}
|
|
2412
|
+
const member = numberedFamilyMember(key.name);
|
|
2413
|
+
return member !== null && (digitFamilies.get(member.base)?.size ?? 0) >= 3;
|
|
2414
|
+
})
|
|
2415
|
+
.map((key) => ({ key: key.name, line: offsetToLine(source, key.offset) }));
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// A numbered vendor family is a base name plus a numeric position and an
|
|
2419
|
+
// optional trailing letter suffix, in either compact/camel form (sensor1,
|
|
2420
|
+
// duty1s) or semantic snake_case form (sensor_1, duty_time_1s). Both styles
|
|
2421
|
+
// normalize to the same { base, position } so a family of >=3 distinct
|
|
2422
|
+
// positions is caught regardless of which naming style the vendor leaked
|
|
2423
|
+
// through. Returns null for names that carry no numeric position.
|
|
2424
|
+
function numberedFamilyMember(name: string): { base: string; position: string } | null {
|
|
2425
|
+
const camelMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(name);
|
|
2426
|
+
if (camelMatch?.[1] && camelMatch[2]) {
|
|
2427
|
+
return { base: camelMatch[1], position: camelMatch[2] };
|
|
2428
|
+
}
|
|
2429
|
+
const snakeMatch = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)*?)_(\d+)[a-z]*$/.exec(name);
|
|
2430
|
+
if (snakeMatch?.[1] && snakeMatch[2]) {
|
|
2431
|
+
return { base: snakeMatch[1], position: snakeMatch[2] };
|
|
2432
|
+
}
|
|
2433
|
+
return null;
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// Public output keys may use APIFuse lowerCamelCase (isOpen24h, latitude) or
|
|
2437
|
+
// semantic snake_case (pharmacy_id, weekly_hours, total_count, scan_exhausted).
|
|
2438
|
+
// Both are normalized, human-authored names. Raw vendor keys leak through mixed
|
|
2439
|
+
// case or uppercase acronyms (MKioskTy) and match neither, so they stay flagged.
|
|
2440
|
+
// Numbered vendor families still pass this name gate in either style
|
|
2441
|
+
// (sensor1/2/3 or sensor_1/sensor_2/sensor_3), so they are caught separately by
|
|
2442
|
+
// the >=3-member numberedFamilyMember check in vendorKeyFindingsForObject.
|
|
2443
|
+
function isAllowedPublicOutputKeyName(name: string): boolean {
|
|
2444
|
+
const isLowerCamelCase = /^[a-z][a-zA-Z0-9]*$/.test(name);
|
|
2445
|
+
const isSemanticSnakeCase = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(name);
|
|
2446
|
+
return isLowerCamelCase || isSemanticSnakeCase;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function collectTopLevelObjectKeys(
|
|
2450
|
+
source: string,
|
|
2451
|
+
objectStart: number,
|
|
2452
|
+
objectEnd: number,
|
|
2453
|
+
): Array<{ name: string; offset: number }> {
|
|
2454
|
+
const keys: Array<{ name: string; offset: number }> = [];
|
|
2455
|
+
const masked = maskCommentsAndStrings(source);
|
|
2456
|
+
let index = objectStart + 1;
|
|
2457
|
+
while (index < objectEnd) {
|
|
2458
|
+
index = skipWhitespaceAndComments(masked, index, objectEnd);
|
|
2459
|
+
if (index >= objectEnd || masked[index] === "}") {
|
|
2460
|
+
break;
|
|
2461
|
+
}
|
|
2462
|
+
const keyStart = index;
|
|
2463
|
+
let key: string | undefined;
|
|
2464
|
+
const quote = source[index];
|
|
2465
|
+
if (quote === '"' || quote === "'") {
|
|
2466
|
+
const endQuote = findStringEnd(source, index);
|
|
2467
|
+
if (endQuote === -1) {
|
|
2468
|
+
break;
|
|
2469
|
+
}
|
|
2470
|
+
key = source.slice(index + 1, endQuote);
|
|
2471
|
+
index = endQuote + 1;
|
|
2472
|
+
} else if (masked[index] === "[") {
|
|
2473
|
+
const computedEnd = findMatchingBracket(masked, index);
|
|
2474
|
+
const literalStart = findNextNonWhitespace(masked, index + 1);
|
|
2475
|
+
if (computedEnd === -1 || literalStart === -1) {
|
|
2476
|
+
break;
|
|
2477
|
+
}
|
|
2478
|
+
const computedQuote = source[literalStart];
|
|
2479
|
+
if (computedQuote === '"' || computedQuote === "'") {
|
|
2480
|
+
const literalEnd = findStringEnd(source, literalStart);
|
|
2481
|
+
const afterLiteral =
|
|
2482
|
+
literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
|
|
2483
|
+
if (literalEnd !== -1 && afterLiteral === computedEnd) {
|
|
2484
|
+
key = source.slice(literalStart + 1, literalEnd);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
index = computedEnd + 1;
|
|
2488
|
+
} else {
|
|
2489
|
+
const idMatch = /^[A-Za-z_$][\w$]*/.exec(masked.slice(index));
|
|
2490
|
+
if (idMatch?.[0]) {
|
|
2491
|
+
key = idMatch[0];
|
|
2492
|
+
index += idMatch[0].length;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
index = skipWhitespaceAndComments(masked, index, objectEnd);
|
|
2496
|
+
if (key && masked[index] === ":") {
|
|
2497
|
+
keys.push({ name: key, offset: keyStart });
|
|
2498
|
+
index = skipObjectValue(masked, index + 1, objectEnd);
|
|
2499
|
+
} else {
|
|
2500
|
+
// Spread-based composition is intentionally not expanded here; this gate
|
|
2501
|
+
// only evaluates keys visible in the object literal.
|
|
2502
|
+
index = skipObjectValue(masked, index, objectEnd);
|
|
2503
|
+
}
|
|
2504
|
+
if (masked[index] === ",") {
|
|
2505
|
+
index += 1;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
return keys;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
|
|
2512
|
+
const findings: SourceFinding[] = [];
|
|
2513
|
+
const seen = new Set<string>();
|
|
2514
|
+
|
|
2515
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2516
|
+
const source = readFileSync(filePath, "utf8");
|
|
2517
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2518
|
+
const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
|
|
2519
|
+
start: zObject.callStart,
|
|
2520
|
+
end: zObject.objectEnd,
|
|
2521
|
+
}));
|
|
2522
|
+
const upstreamRanges = findUpstreamMarkedConstRanges(source);
|
|
2523
|
+
const fixtureRanges = findPropertyObjectRanges(source, "fixtures");
|
|
2524
|
+
const fixtureResponseRanges = [
|
|
2525
|
+
...findPropertyObjectRanges(source, "response"),
|
|
2526
|
+
...findPropertyObjectRanges(source, "output"),
|
|
2527
|
+
].filter((range) => rangeContainedInRanges(fixtureRanges, range));
|
|
2528
|
+
|
|
2529
|
+
for (const range of fixtureResponseRanges) {
|
|
2530
|
+
for (const literal of findStringLiteralsInRange(source, range)) {
|
|
2531
|
+
if (
|
|
2532
|
+
rangeContainsOffset(zObjectRanges, literal.offset) ||
|
|
2533
|
+
rangeContainsOffset(upstreamRanges, literal.offset) ||
|
|
2534
|
+
!isVendorTimestampCandidate(
|
|
2535
|
+
literal.value,
|
|
2536
|
+
propertyKeyForStringLiteral(source, literal.offset),
|
|
2537
|
+
)
|
|
2538
|
+
) {
|
|
2539
|
+
continue;
|
|
2540
|
+
}
|
|
2541
|
+
const line = offsetToLine(source, literal.offset);
|
|
2542
|
+
const key = `${relPath}:${line}:${literal.value}`;
|
|
2543
|
+
if (!seen.has(key)) {
|
|
2544
|
+
seen.add(key);
|
|
2545
|
+
findings.push({ file: relPath, line });
|
|
2546
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2547
|
+
return findings;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
return findings;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
|
|
2558
|
+
const ranges: ObjectRange[] = [];
|
|
2559
|
+
const masked = maskCommentsAndStrings(source);
|
|
2560
|
+
const escaped = propertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2561
|
+
const pattern = new RegExp(`(?:^|[^\\w$])["']?${escaped}["']?\\s*:`, "g");
|
|
2562
|
+
for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
|
|
2563
|
+
const objectStart = findNextNonWhitespace(masked, match.index + match[0].length);
|
|
2564
|
+
if (objectStart === -1 || masked[objectStart] !== "{") {
|
|
2565
|
+
continue;
|
|
2566
|
+
}
|
|
2567
|
+
const objectEnd = findMatchingBracket(masked, objectStart);
|
|
2568
|
+
if (objectEnd === -1) {
|
|
2569
|
+
continue;
|
|
2570
|
+
}
|
|
2571
|
+
ranges.push({ start: objectStart, end: objectEnd });
|
|
2572
|
+
pattern.lastIndex = objectEnd;
|
|
2573
|
+
}
|
|
2574
|
+
return ranges;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
function findUpstreamMarkedConstRanges(source: string): ObjectRange[] {
|
|
2578
|
+
return findNamedConstValueRanges(source)
|
|
2579
|
+
.filter((range) => /upstream|raw|vendor/i.test(range.name))
|
|
2580
|
+
.map(({ start, end }) => ({ start, end }));
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function findNamedConstValueRanges(source: string): NamedObjectRange[] {
|
|
2584
|
+
const ranges: NamedObjectRange[] = [];
|
|
2585
|
+
const masked = maskCommentsAndStrings(source);
|
|
2586
|
+
const pattern =
|
|
2587
|
+
/(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
|
|
2588
|
+
for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
|
|
2589
|
+
const name = match[1];
|
|
2590
|
+
if (!name) {
|
|
2591
|
+
continue;
|
|
2592
|
+
}
|
|
2593
|
+
const start = match.index + match[0].length;
|
|
2594
|
+
const expression = balancedValueExpression(masked, start);
|
|
2595
|
+
ranges.push({ name, start, end: start + expression.length });
|
|
2596
|
+
}
|
|
2597
|
+
return ranges;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
function findConstValueRangeContaining(
|
|
2601
|
+
source: string,
|
|
2602
|
+
offset: number,
|
|
2603
|
+
): NamedObjectRange | undefined {
|
|
2604
|
+
return findNamedConstValueRanges(source).find(
|
|
2605
|
+
(range) => offset >= range.start && offset <= range.end,
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function findStringLiteralsInRange(
|
|
2610
|
+
source: string,
|
|
2611
|
+
range: ObjectRange,
|
|
2612
|
+
): Array<{ value: string; offset: number }> {
|
|
2613
|
+
const literals: Array<{ value: string; offset: number }> = [];
|
|
2614
|
+
let index = range.start;
|
|
2615
|
+
while (index <= range.end) {
|
|
2616
|
+
const quote = source[index];
|
|
2617
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2618
|
+
index += 1;
|
|
2619
|
+
continue;
|
|
2620
|
+
}
|
|
2621
|
+
const end = findStringEnd(source, index);
|
|
2622
|
+
if (end === -1) {
|
|
2623
|
+
break;
|
|
2624
|
+
}
|
|
2625
|
+
if (quote === "`" && source.slice(index + 1, end).includes("${")) {
|
|
2626
|
+
index = end + 1;
|
|
2627
|
+
continue;
|
|
2628
|
+
}
|
|
2629
|
+
literals.push({ value: source.slice(index + 1, end), offset: index });
|
|
2630
|
+
index = end + 1;
|
|
2631
|
+
}
|
|
2632
|
+
return literals;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
function isVendorTimestampCandidate(value: string, key: string | undefined): boolean {
|
|
2636
|
+
if (/^\d{8}$/.test(value)) {
|
|
2637
|
+
return isPlausibleCompactDate(value);
|
|
2638
|
+
}
|
|
2639
|
+
if (/^\d{12}$/.test(value)) {
|
|
2640
|
+
return isPlausibleCompactDate(value.slice(0, 8)) && isPlausibleHourMinute(value.slice(8, 12));
|
|
2641
|
+
}
|
|
2642
|
+
if (/^\d{14}$/.test(value)) {
|
|
2643
|
+
const seconds = Number(value.slice(12, 14));
|
|
2644
|
+
return (
|
|
2645
|
+
isPlausibleCompactDate(value.slice(0, 8)) &&
|
|
2646
|
+
isPlausibleHourMinute(value.slice(8, 12)) &&
|
|
2647
|
+
seconds >= 0 &&
|
|
2648
|
+
seconds <= 59
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
if (
|
|
2652
|
+
/^\d{4}$/.test(value) &&
|
|
2653
|
+
key !== undefined &&
|
|
2654
|
+
/(?:^|_)at$|At$|time|date|open|close|updated|created/i.test(key)
|
|
2655
|
+
) {
|
|
2656
|
+
return isPlausibleHourMinute(value);
|
|
2657
|
+
}
|
|
2658
|
+
return false;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
function isPlausibleCompactDate(value: string): boolean {
|
|
2662
|
+
const year = Number(value.slice(0, 4));
|
|
2663
|
+
const month = Number(value.slice(4, 6));
|
|
2664
|
+
const day = Number(value.slice(6, 8));
|
|
2665
|
+
return year >= 1900 && year <= 2099 && month >= 1 && month <= 12 && day >= 1 && day <= 31;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
function isPlausibleHourMinute(value: string): boolean {
|
|
2669
|
+
const hour = Number(value.slice(0, 2));
|
|
2670
|
+
const minute = Number(value.slice(2, 4));
|
|
2671
|
+
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function findNextNonWhitespace(source: string, start: number): number {
|
|
2675
|
+
for (let index = start; index < source.length; index += 1) {
|
|
2676
|
+
if (!/\s/.test(source[index] ?? "")) {
|
|
2677
|
+
return index;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
return -1;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
function findMatchingBracket(source: string, openIndex: number): number {
|
|
2684
|
+
const open = source[openIndex];
|
|
2685
|
+
const close = open === "{" ? "}" : open === "(" ? ")" : open === "[" ? "]" : undefined;
|
|
2686
|
+
if (!close) {
|
|
2687
|
+
return -1;
|
|
2688
|
+
}
|
|
2689
|
+
let depth = 0;
|
|
2690
|
+
for (let index = openIndex; index < source.length; index += 1) {
|
|
2691
|
+
const char = source[index];
|
|
2692
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
2693
|
+
const stringEnd = findStringEnd(source, index);
|
|
2694
|
+
if (stringEnd === -1) {
|
|
2695
|
+
return -1;
|
|
2696
|
+
}
|
|
2697
|
+
index = stringEnd;
|
|
2698
|
+
continue;
|
|
2699
|
+
}
|
|
2700
|
+
if (char === open) {
|
|
2701
|
+
depth += 1;
|
|
2702
|
+
} else if (char === close) {
|
|
2703
|
+
depth -= 1;
|
|
2704
|
+
if (depth === 0) {
|
|
2705
|
+
return index;
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
return -1;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
function findStringEnd(source: string, start: number): number {
|
|
2713
|
+
const quote = source[start];
|
|
2714
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
2715
|
+
if (source[index] === "\\") {
|
|
2716
|
+
index += 1;
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
if (source[index] === quote) {
|
|
2720
|
+
return index;
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
return -1;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
function maskCommentsAndStrings(source: string): string {
|
|
2727
|
+
const chars = source.split("");
|
|
2728
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
2729
|
+
if (source.startsWith("//", index)) {
|
|
2730
|
+
const bodyStart = index + 2;
|
|
2731
|
+
const newline = source.indexOf("\n", bodyStart);
|
|
2732
|
+
const end = newline === -1 ? source.length : newline;
|
|
2733
|
+
for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
|
|
2734
|
+
chars[bodyIndex] = " ";
|
|
2735
|
+
}
|
|
2736
|
+
index = end;
|
|
2737
|
+
continue;
|
|
2738
|
+
}
|
|
2739
|
+
if (source.startsWith("/*", index)) {
|
|
2740
|
+
const bodyStart = index + 2;
|
|
2741
|
+
const close = source.indexOf("*/", bodyStart);
|
|
2742
|
+
const end = close === -1 ? source.length : close;
|
|
2743
|
+
for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
|
|
2744
|
+
if (chars[bodyIndex] !== "\n") {
|
|
2745
|
+
chars[bodyIndex] = " ";
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
index = close === -1 ? source.length : close + 1;
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
2751
|
+
const quote = source[index];
|
|
2752
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2755
|
+
const end = findStringEnd(source, index);
|
|
2756
|
+
if (end === -1) {
|
|
2757
|
+
break;
|
|
2758
|
+
}
|
|
2759
|
+
// Preserve quoted property keys ("response": ...) so range/key scanners
|
|
2760
|
+
// can still match them; only string VALUES are blanked.
|
|
2761
|
+
let probe = end + 1;
|
|
2762
|
+
while (probe < source.length && /\s/.test(source[probe] ?? "")) {
|
|
2763
|
+
probe += 1;
|
|
2764
|
+
}
|
|
2765
|
+
if (source[probe] !== ":") {
|
|
2766
|
+
for (let bodyIndex = index + 1; bodyIndex < end; bodyIndex += 1) {
|
|
2767
|
+
if (chars[bodyIndex] !== "\n") {
|
|
2768
|
+
chars[bodyIndex] = " ";
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
index = end;
|
|
2773
|
+
}
|
|
2774
|
+
return chars.join("");
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
function skipWhitespaceAndComments(source: string, start: number, end: number): number {
|
|
2778
|
+
let index = start;
|
|
2779
|
+
while (index < end) {
|
|
2780
|
+
if (/\s/.test(source[index] ?? "")) {
|
|
2781
|
+
index += 1;
|
|
2782
|
+
continue;
|
|
2783
|
+
}
|
|
2784
|
+
if (source.startsWith("//", index)) {
|
|
2785
|
+
const newline = source.indexOf("\n", index + 2);
|
|
2786
|
+
index = newline === -1 ? end : newline + 1;
|
|
2787
|
+
continue;
|
|
2788
|
+
}
|
|
2789
|
+
if (source.startsWith("/*", index)) {
|
|
2790
|
+
const close = source.indexOf("*/", index + 2);
|
|
2791
|
+
index = close === -1 ? end : close + 2;
|
|
2792
|
+
continue;
|
|
2793
|
+
}
|
|
2794
|
+
break;
|
|
2795
|
+
}
|
|
2796
|
+
return index;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
function skipObjectValue(source: string, start: number, end: number): number {
|
|
2800
|
+
let index = start;
|
|
2801
|
+
while (index < end) {
|
|
2802
|
+
const char = source[index];
|
|
2803
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
2804
|
+
const stringEnd = findStringEnd(source, index);
|
|
2805
|
+
if (stringEnd === -1) {
|
|
2806
|
+
return end;
|
|
2807
|
+
}
|
|
2808
|
+
index = stringEnd + 1;
|
|
2809
|
+
continue;
|
|
2810
|
+
}
|
|
2811
|
+
if (char === "{" || char === "(" || char === "[") {
|
|
2812
|
+
const close = findMatchingBracket(source, index);
|
|
2813
|
+
if (close === -1) {
|
|
2814
|
+
return end;
|
|
2815
|
+
}
|
|
2816
|
+
index = close + 1;
|
|
2817
|
+
continue;
|
|
2818
|
+
}
|
|
2819
|
+
if (char === "," || char === "}") {
|
|
2820
|
+
return index;
|
|
2821
|
+
}
|
|
2822
|
+
index += 1;
|
|
2823
|
+
}
|
|
2824
|
+
return index;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function rangeContainsOffset(ranges: readonly ObjectRange[], offset: number): boolean {
|
|
2828
|
+
return ranges.some((range) => offset >= range.start && offset <= range.end);
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
function rangeContainedInRanges(ranges: readonly ObjectRange[], candidate: ObjectRange): boolean {
|
|
2832
|
+
return ranges.some((range) => candidate.start >= range.start && candidate.end <= range.end);
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
function propertyKeyForStringLiteral(source: string, literalOffset: number): string | undefined {
|
|
2836
|
+
const masked = maskCommentsAndStrings(source);
|
|
2837
|
+
let index = skipWhitespaceBackward(masked, literalOffset - 1);
|
|
2838
|
+
if (masked[index] !== ":") {
|
|
2839
|
+
return undefined;
|
|
2840
|
+
}
|
|
2841
|
+
index = skipWhitespaceBackward(masked, index - 1);
|
|
2842
|
+
if (index < 0) {
|
|
2843
|
+
return undefined;
|
|
2844
|
+
}
|
|
2845
|
+
if (source[index] === '"' || source[index] === "'") {
|
|
2846
|
+
const quote = source[index];
|
|
2847
|
+
let start = index - 1;
|
|
2848
|
+
while (start >= 0) {
|
|
2849
|
+
if (source[start] === quote && source[start - 1] !== "\\") {
|
|
2850
|
+
return source.slice(start + 1, index);
|
|
2851
|
+
}
|
|
2852
|
+
start -= 1;
|
|
2853
|
+
}
|
|
2854
|
+
return undefined;
|
|
2855
|
+
}
|
|
2856
|
+
const keyMatch = /[A-Za-z_$][\w$]*$/.exec(masked.slice(0, index + 1));
|
|
2857
|
+
return keyMatch?.[0];
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function skipWhitespaceBackward(source: string, start: number): number {
|
|
2861
|
+
let index = start;
|
|
2862
|
+
while (index >= 0 && /\s/.test(source[index] ?? "")) {
|
|
2863
|
+
index -= 1;
|
|
2864
|
+
}
|
|
2865
|
+
return index;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
2869
|
+
const operations = Object.entries(provider.operations);
|
|
2870
|
+
const missing: string[] = [];
|
|
2871
|
+
const vacuous: string[] = [];
|
|
2872
|
+
const placeholder: string[] = [];
|
|
2873
|
+
const unsupported: string[] = [];
|
|
2874
|
+
const generatedStarter: string[] = [];
|
|
2875
|
+
|
|
2876
|
+
for (const [operationId, operation] of operations) {
|
|
2877
|
+
const hasCheck = operation.healthCheck !== undefined;
|
|
2878
|
+
const hasUnsupported = operation.healthCheckUnsupported !== undefined;
|
|
2879
|
+
if (!hasCheck && !hasUnsupported) {
|
|
2880
|
+
missing.push(operationId);
|
|
2881
|
+
continue;
|
|
2882
|
+
}
|
|
2883
|
+
if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
|
|
2884
|
+
vacuous.push(operationId);
|
|
2885
|
+
}
|
|
2886
|
+
if (hasUnsupported) {
|
|
2887
|
+
const reason = operation.healthCheckUnsupported?.reason ?? "";
|
|
2888
|
+
unsupported.push(operationId);
|
|
2889
|
+
if (/generated local-only scaffold/i.test(reason)) {
|
|
2890
|
+
generatedStarter.push(operationId);
|
|
2891
|
+
}
|
|
2892
|
+
if (
|
|
2893
|
+
/(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(reason)
|
|
2894
|
+
) {
|
|
2895
|
+
placeholder.push(operationId);
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
if (missing.length > 0) {
|
|
2901
|
+
return blocker(
|
|
2902
|
+
"health-coverage",
|
|
2903
|
+
"health",
|
|
2904
|
+
"One or more operations lack healthCheck or healthCheckUnsupported.",
|
|
2905
|
+
`For ${missing.join(", ")}, add \`healthCheck: { interval, cases }\` for safe read-only upstream probes, or add \`healthCheckUnsupported: { reason: "<specific reason>" }\`.`,
|
|
2906
|
+
CATEGORY_MAX_POINTS.health,
|
|
2907
|
+
missing,
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
if (vacuous.length > 0) {
|
|
2912
|
+
return blocker(
|
|
2913
|
+
"health-coverage",
|
|
2914
|
+
"health",
|
|
2915
|
+
"One or more operations have healthCheck cases with empty assertions.",
|
|
2916
|
+
`healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
|
|
2917
|
+
CATEGORY_MAX_POINTS.health,
|
|
2918
|
+
vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
if (placeholder.length > 0) {
|
|
2923
|
+
return {
|
|
2924
|
+
id: "health-coverage",
|
|
2925
|
+
category: "health",
|
|
2926
|
+
level: "warn",
|
|
2927
|
+
status: "warn",
|
|
2928
|
+
points: 8,
|
|
2929
|
+
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2930
|
+
message: "Some healthCheckUnsupported reasons look placeholder-like.",
|
|
2931
|
+
remediation: `For ${placeholder.join(", ")}, replace the placeholder \`healthCheckUnsupported.reason\` with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.`,
|
|
2932
|
+
evidence: placeholder,
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
if (generatedStarter.length > 0) {
|
|
2937
|
+
return {
|
|
2938
|
+
id: "health-coverage",
|
|
2939
|
+
category: "health",
|
|
2940
|
+
level: "warn",
|
|
2941
|
+
status: "warn",
|
|
2942
|
+
points: 10,
|
|
2943
|
+
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2944
|
+
message:
|
|
2945
|
+
"Generated starter operation health rationale is present; replace starter logic before bounty submission.",
|
|
2946
|
+
remediation: `Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
|
|
2947
|
+
evidence: generatedStarter,
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
if (unsupported.length > 0) {
|
|
2952
|
+
return {
|
|
2953
|
+
id: "health-coverage",
|
|
2954
|
+
category: "health",
|
|
2955
|
+
level: "warn",
|
|
2956
|
+
status: "warn",
|
|
2957
|
+
points: 12,
|
|
2958
|
+
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2959
|
+
message: "Health coverage is declared, with one or more unsupported probes.",
|
|
2960
|
+
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.`,
|
|
2961
|
+
evidence: unsupported.map((operationId) => `${operationId}: healthCheckUnsupported`),
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
return pass(
|
|
2151
2966
|
"health-coverage",
|
|
2152
2967
|
"health",
|
|
2153
2968
|
"All operations declare real health checks.",
|
|
@@ -2155,8 +2970,430 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2155
2970
|
);
|
|
2156
2971
|
}
|
|
2157
2972
|
|
|
2158
|
-
function
|
|
2159
|
-
|
|
2973
|
+
function hasOnlyVacuousHealthCases(
|
|
2974
|
+
healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
|
|
2975
|
+
): boolean {
|
|
2976
|
+
const cases = healthCheck?.cases;
|
|
2977
|
+
if (!Array.isArray(cases) || cases.length === 0) {
|
|
2978
|
+
return true;
|
|
2979
|
+
}
|
|
2980
|
+
return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function isVacuousAssertionFunction(assertions: unknown): boolean {
|
|
2984
|
+
if (typeof assertions !== "function") {
|
|
2985
|
+
return true;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
let source: string;
|
|
2989
|
+
try {
|
|
2990
|
+
source = Function.prototype.toString.call(assertions);
|
|
2991
|
+
} catch {
|
|
2992
|
+
return false;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
// Native / bound functions stringify to `function () { [native code] }` with
|
|
2996
|
+
// no inspectable body or params. The underlying implementation may inspect
|
|
2997
|
+
// ctx, so fail open (do not flag) rather than mistake it for an empty body.
|
|
2998
|
+
if (/\[native code\]/.test(source)) {
|
|
2999
|
+
return false;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
const fn = parseAssertionFunction(source);
|
|
3003
|
+
if (!fn) {
|
|
3004
|
+
// Unparseable source → fail open (treat as a real assertion). A false
|
|
3005
|
+
// negative here only misses a no-op; a false positive would wrongly
|
|
3006
|
+
// reject a valid contributor.
|
|
3007
|
+
return false;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
// A real health assertion MUST either throw when the upstream contract
|
|
3011
|
+
// breaks, or inspect the probe response, which is delivered exclusively
|
|
3012
|
+
// through the assertion's own parameter(s). Working on the parsed AST (not
|
|
3013
|
+
// text) makes this precise at the syntactic layer: a `throw` only counts
|
|
3014
|
+
// when it is a real ThrowStatement in THIS function's body (not inside a
|
|
3015
|
+
// nested, uninvoked function), and a parameter reference is checked against
|
|
3016
|
+
// the actual bound names (destructuring binds the local alias, not the
|
|
3017
|
+
// property key). This closes the whole equivalent-no-op class — empty
|
|
3018
|
+
// bodies, `void 0`, `({})`, `Promise.resolve()`, `await Promise.resolve()`,
|
|
3019
|
+
// `.then()`, `new Promise(r => r())`, side-effect-only bodies, throws hidden
|
|
3020
|
+
// in uninvoked closures — without enumerating spellings.
|
|
3021
|
+
if (functionThrows(fn)) {
|
|
3022
|
+
return false;
|
|
3023
|
+
}
|
|
3024
|
+
const bound = new Set<string>();
|
|
3025
|
+
for (const param of fn.params) {
|
|
3026
|
+
collectBoundNames(param, bound);
|
|
3027
|
+
}
|
|
3028
|
+
if (bound.size === 0) {
|
|
3029
|
+
return true;
|
|
3030
|
+
}
|
|
3031
|
+
// A parameter reference anywhere in the (reachable) body is treated as
|
|
3032
|
+
// inspecting the response. This is deliberately syntactic, not a dataflow
|
|
3033
|
+
// analysis.
|
|
3034
|
+
//
|
|
3035
|
+
// KNOWN LIMITATION (accepted): a body that reads the parameter but never
|
|
3036
|
+
// turns that read into an outcome — no throw, no returned verdict — still
|
|
3037
|
+
// passes, e.g. `({ status }) => { console.info(status); }`. Precisely
|
|
3038
|
+
// rejecting it would require tracking whether the read flows to a throw
|
|
3039
|
+
// argument or return value through arbitrary local bindings and invoked
|
|
3040
|
+
// helpers (`const ok = ctx.output.ok; return ok ? ...` / a called helper that
|
|
3041
|
+
// throws). That is transitive use-def dataflow, and an imprecise version
|
|
3042
|
+
// FALSE-BLOCKS real assertions of exactly those shapes — verified
|
|
3043
|
+
// empirically. Under the fail-open contract (rejecting a real contributor is
|
|
3044
|
+
// strictly worse than missing a no-op) we accept the miss here. This gate
|
|
3045
|
+
// stops accidental/lazy no-ops (empty bodies, `void 0`, `Promise.resolve()`,
|
|
3046
|
+
// throws in uninvoked closures); a determined bypass via a decorative ctx
|
|
3047
|
+
// read is no easier than writing the real one-line `throw`, and the actual
|
|
3048
|
+
// defense against a runtime-empty assertion is the live `--smoke` probe.
|
|
3049
|
+
return !referencesBoundNames(fn, bound);
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
type AssertionFunctionNode =
|
|
3053
|
+
| acorn.ArrowFunctionExpression
|
|
3054
|
+
| acorn.FunctionExpression
|
|
3055
|
+
| acorn.FunctionDeclaration;
|
|
3056
|
+
|
|
3057
|
+
function isFunctionNode(node: acorn.AnyNode): node is AssertionFunctionNode {
|
|
3058
|
+
return (
|
|
3059
|
+
node.type === "ArrowFunctionExpression" ||
|
|
3060
|
+
node.type === "FunctionExpression" ||
|
|
3061
|
+
node.type === "FunctionDeclaration"
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
/**
|
|
3066
|
+
* Parse the `Function.prototype.toString()` output of an assertion into its AST
|
|
3067
|
+
* function node. The stringified form can be an arrow (`(a) => {}`), a function
|
|
3068
|
+
* expression (`function (a) {}`), or a bare method (`foo() {}`), so try a few
|
|
3069
|
+
* wrappers until one parses. Returns undefined on any parse failure so callers
|
|
3070
|
+
* fail open.
|
|
3071
|
+
*/
|
|
3072
|
+
function parseAssertionFunction(source: string): AssertionFunctionNode | undefined {
|
|
3073
|
+
const candidates = [source, `(${source})`, `({${source}})`];
|
|
3074
|
+
for (const candidate of candidates) {
|
|
3075
|
+
let program: acorn.Program;
|
|
3076
|
+
try {
|
|
3077
|
+
program = acorn.parse(candidate, { ecmaVersion: "latest" });
|
|
3078
|
+
} catch {
|
|
3079
|
+
continue;
|
|
3080
|
+
}
|
|
3081
|
+
const fn = findFirstFunction(program);
|
|
3082
|
+
if (fn) {
|
|
3083
|
+
return fn;
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
return undefined;
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
/** Depth-first search for the first function node in a parsed program. */
|
|
3090
|
+
function findFirstFunction(root: acorn.AnyNode): AssertionFunctionNode | undefined {
|
|
3091
|
+
let found: AssertionFunctionNode | undefined;
|
|
3092
|
+
walkAst(root, (node) => {
|
|
3093
|
+
if (found) {
|
|
3094
|
+
return false;
|
|
3095
|
+
}
|
|
3096
|
+
if (isFunctionNode(node)) {
|
|
3097
|
+
found = node;
|
|
3098
|
+
return false;
|
|
3099
|
+
}
|
|
3100
|
+
return true;
|
|
3101
|
+
});
|
|
3102
|
+
return found;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
/**
|
|
3106
|
+
* Collect the identifier names actually BOUND by a parameter pattern. For
|
|
3107
|
+
* destructuring, the binding is the local target (`value`), not the source
|
|
3108
|
+
* property key — so `({ status: ignored })` binds `ignored`, and a body that
|
|
3109
|
+
* merely mentions `status` is not referencing a parameter.
|
|
3110
|
+
*/
|
|
3111
|
+
function collectBoundNames(pattern: acorn.Pattern | null, out: Set<string>): void {
|
|
3112
|
+
if (!pattern) {
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
switch (pattern.type) {
|
|
3116
|
+
case "Identifier":
|
|
3117
|
+
out.add(pattern.name);
|
|
3118
|
+
break;
|
|
3119
|
+
case "AssignmentPattern":
|
|
3120
|
+
collectBoundNames(pattern.left, out);
|
|
3121
|
+
break;
|
|
3122
|
+
case "RestElement":
|
|
3123
|
+
collectBoundNames(pattern.argument, out);
|
|
3124
|
+
break;
|
|
3125
|
+
case "ArrayPattern":
|
|
3126
|
+
for (const element of pattern.elements) {
|
|
3127
|
+
collectBoundNames(element, out);
|
|
3128
|
+
}
|
|
3129
|
+
break;
|
|
3130
|
+
case "ObjectPattern":
|
|
3131
|
+
for (const property of pattern.properties) {
|
|
3132
|
+
if (property.type === "RestElement") {
|
|
3133
|
+
collectBoundNames(property.argument, out);
|
|
3134
|
+
} else {
|
|
3135
|
+
// `.value` is the local binding target, `.key` is the source
|
|
3136
|
+
// property name — bind only the former.
|
|
3137
|
+
collectBoundNames(property.value, out);
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
break;
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
/**
|
|
3145
|
+
* True if the function contains a real `throw` statement in ITS OWN body —
|
|
3146
|
+
* descending through control flow but NOT into nested functions, whose throws
|
|
3147
|
+
* do not execute unless that nested function is invoked.
|
|
3148
|
+
*/
|
|
3149
|
+
function functionThrows(fn: AssertionFunctionNode): boolean {
|
|
3150
|
+
if (fn.body.type !== "BlockStatement") {
|
|
3151
|
+
// Concise arrow returning an expression cannot contain a throw statement.
|
|
3152
|
+
return false;
|
|
3153
|
+
}
|
|
3154
|
+
let throws = false;
|
|
3155
|
+
walkAst(fn.body, (node) => {
|
|
3156
|
+
if (throws) {
|
|
3157
|
+
return false;
|
|
3158
|
+
}
|
|
3159
|
+
if (node.type === "ThrowStatement") {
|
|
3160
|
+
throws = true;
|
|
3161
|
+
return false;
|
|
3162
|
+
}
|
|
3163
|
+
// Do not descend into nested function bodies.
|
|
3164
|
+
if (isFunctionNode(node)) {
|
|
3165
|
+
return false;
|
|
3166
|
+
}
|
|
3167
|
+
return true;
|
|
3168
|
+
});
|
|
3169
|
+
return throws;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
/**
|
|
3173
|
+
* True if the function's body references any of the given bound parameter names
|
|
3174
|
+
* as an actual value. Property KEYS (`{ status: ... }`, `obj.status`) are not
|
|
3175
|
+
* references; computed members (`obj[status]`) are. Nested functions that
|
|
3176
|
+
* re-bind the same name shadow it, so their bodies are searched with the
|
|
3177
|
+
* shadowed name removed from the target set.
|
|
3178
|
+
*/
|
|
3179
|
+
function referencesBoundNames(fn: AssertionFunctionNode, bound: Set<string>): boolean {
|
|
3180
|
+
if (bound.size === 0) {
|
|
3181
|
+
return false;
|
|
3182
|
+
}
|
|
3183
|
+
let referenced = false;
|
|
3184
|
+
walkAstValues(fn.body, bound, fn.body, null, null, () => {
|
|
3185
|
+
referenced = true;
|
|
3186
|
+
});
|
|
3187
|
+
return referenced;
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
/**
|
|
3191
|
+
* Walk `node`, invoking `onReference` when an Identifier in value position
|
|
3192
|
+
* matches a name in `names`. Skips property keys and non-computed member
|
|
3193
|
+
* properties. On entering a nested function, removes any parameter names it
|
|
3194
|
+
* rebinds (shadowing) from the active set for that subtree, and does NOT descend
|
|
3195
|
+
* into a PROVABLY-UNINVOKED helper — a function bound to a local name that is
|
|
3196
|
+
* never referenced again anywhere in the assertion body, so it cannot run when
|
|
3197
|
+
* the assertion runs (e.g. `(ctx) => { const later = () => ctx.status; }`). Its
|
|
3198
|
+
* parameter reads therefore must not count as inspecting the response, mirroring
|
|
3199
|
+
* how `functionThrows` ignores throws inside nested functions.
|
|
3200
|
+
*
|
|
3201
|
+
* Crucially, a helper that IS referenced again (a call site like `check()`) is
|
|
3202
|
+
* NOT skipped — its body is searched — so real assertions that factor the check
|
|
3203
|
+
* into a local helper still pass. Immediately-invoked callbacks (`.every(cb)`,
|
|
3204
|
+
* IIFEs, callees) are likewise searched. When in doubt we descend (fail open):
|
|
3205
|
+
* the only skip is a helper we can prove is never invoked.
|
|
3206
|
+
*/
|
|
3207
|
+
function walkAstValues(
|
|
3208
|
+
node: acorn.AnyNode,
|
|
3209
|
+
names: Set<string>,
|
|
3210
|
+
outerBody: acorn.AnyNode,
|
|
3211
|
+
parent: acorn.AnyNode | null,
|
|
3212
|
+
parentKey: string | null,
|
|
3213
|
+
onReference: () => void,
|
|
3214
|
+
): void {
|
|
3215
|
+
if (names.size === 0) {
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
if (node.type === "Identifier") {
|
|
3219
|
+
if (names.has(node.name)) {
|
|
3220
|
+
onReference();
|
|
3221
|
+
}
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
// Nested function: subtract its own parameter bindings (shadowing) before
|
|
3225
|
+
// descending into its body — and skip it only when it is a provably
|
|
3226
|
+
// uninvoked helper.
|
|
3227
|
+
if (isFunctionNode(node)) {
|
|
3228
|
+
const shadowed = new Set<string>();
|
|
3229
|
+
for (const param of node.params) {
|
|
3230
|
+
collectBoundNames(param, shadowed);
|
|
3231
|
+
}
|
|
3232
|
+
const visible = new Set<string>();
|
|
3233
|
+
for (const name of names) {
|
|
3234
|
+
if (!shadowed.has(name)) {
|
|
3235
|
+
visible.add(name);
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
if (visible.size === 0 || isProvablyUninvokedHelper(node, parent, parentKey, outerBody)) {
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
for (const [key, child] of childEntries(node)) {
|
|
3242
|
+
if (key === "params") {
|
|
3243
|
+
continue;
|
|
3244
|
+
}
|
|
3245
|
+
walkAstValues(child, visible, outerBody, node, key, onReference);
|
|
3246
|
+
}
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
3249
|
+
for (const [key, child] of childEntries(node)) {
|
|
3250
|
+
// Skip non-computed property keys (`{ status: x }`) and member
|
|
3251
|
+
// properties (`obj.status`) — these are names, not references.
|
|
3252
|
+
if (key === "key" && node.type === "Property" && !node.computed) {
|
|
3253
|
+
continue;
|
|
3254
|
+
}
|
|
3255
|
+
if (key === "property" && node.type === "MemberExpression" && !node.computed) {
|
|
3256
|
+
continue;
|
|
3257
|
+
}
|
|
3258
|
+
walkAstValues(child, names, outerBody, node, key, onReference);
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
/**
|
|
3263
|
+
* True if `fn` is a local helper bound to a name that is NEVER referenced again
|
|
3264
|
+
* anywhere in `outerBody` — meaning it is never invoked, so its body does not run
|
|
3265
|
+
* as part of evaluating the assertion. Only these provably-dead helpers are
|
|
3266
|
+
* skipped; a helper with any call site (its name appearing more than once, i.e.
|
|
3267
|
+
* beyond its own declaration) is treated as potentially executed and searched.
|
|
3268
|
+
* Anonymous functions in expression position (call args, callees, returns) are
|
|
3269
|
+
* never "uninvoked helpers" — they may run — so they are not skipped here.
|
|
3270
|
+
*/
|
|
3271
|
+
function isProvablyUninvokedHelper(
|
|
3272
|
+
fn: AssertionFunctionNode,
|
|
3273
|
+
parent: acorn.AnyNode | null,
|
|
3274
|
+
parentKey: string | null,
|
|
3275
|
+
outerBody: acorn.AnyNode,
|
|
3276
|
+
): boolean {
|
|
3277
|
+
let helperName: string | undefined;
|
|
3278
|
+
if (fn.type === "FunctionDeclaration" && fn.id) {
|
|
3279
|
+
helperName = fn.id.name;
|
|
3280
|
+
} else if (
|
|
3281
|
+
parent &&
|
|
3282
|
+
parent.type === "VariableDeclarator" &&
|
|
3283
|
+
parentKey === "init" &&
|
|
3284
|
+
parent.id.type === "Identifier"
|
|
3285
|
+
) {
|
|
3286
|
+
helperName = parent.id.name;
|
|
3287
|
+
}
|
|
3288
|
+
if (helperName === undefined) {
|
|
3289
|
+
// Not a name-bound helper (anonymous callback / expression). It may run,
|
|
3290
|
+
// so do not skip it.
|
|
3291
|
+
return false;
|
|
3292
|
+
}
|
|
3293
|
+
// Count every occurrence of the helper name in the assertion body. Exactly
|
|
3294
|
+
// one occurrence is its own binding declaration; more than one means there is
|
|
3295
|
+
// at least one reference (call site), so the helper can run.
|
|
3296
|
+
let occurrences = 0;
|
|
3297
|
+
walkAst(outerBody, (node) => {
|
|
3298
|
+
if (node.type === "Identifier" && node.name === helperName) {
|
|
3299
|
+
occurrences += 1;
|
|
3300
|
+
}
|
|
3301
|
+
return true;
|
|
3302
|
+
});
|
|
3303
|
+
return occurrences <= 1;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
/** Generic pre-order AST walk; `visit` returns false to stop descending. */
|
|
3307
|
+
function walkAst(node: acorn.AnyNode, visit: (node: acorn.AnyNode) => boolean): void {
|
|
3308
|
+
if (!visit(node)) {
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
for (const child of childNodes(node)) {
|
|
3312
|
+
walkAst(child, visit);
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
function* childNodes(node: acorn.AnyNode): Generator<acorn.AnyNode> {
|
|
3317
|
+
for (const [, child] of childEntries(node)) {
|
|
3318
|
+
yield child;
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
function* childEntries(node: acorn.AnyNode): Generator<[string, acorn.AnyNode]> {
|
|
3323
|
+
for (const key of Object.keys(node)) {
|
|
3324
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") {
|
|
3325
|
+
continue;
|
|
3326
|
+
}
|
|
3327
|
+
const value = (node as unknown as Record<string, unknown>)[key];
|
|
3328
|
+
if (Array.isArray(value)) {
|
|
3329
|
+
for (const item of value) {
|
|
3330
|
+
if (isAstNode(item)) {
|
|
3331
|
+
yield [key, item];
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
} else if (isAstNode(value)) {
|
|
3335
|
+
yield [key, value];
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
function isAstNode(value: unknown): value is acorn.AnyNode {
|
|
3341
|
+
return (
|
|
3342
|
+
typeof value === "object" &&
|
|
3343
|
+
value !== null &&
|
|
3344
|
+
typeof (value as { type?: unknown }).type === "string"
|
|
3345
|
+
);
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
function scoreSmoke(
|
|
3349
|
+
smokeResult: SmokeResult | undefined,
|
|
3350
|
+
smokeNote: string | undefined,
|
|
3351
|
+
): SubmitCheck {
|
|
3352
|
+
const deprecatedEvidence = smokeNote?.trim()
|
|
3353
|
+
? ["Deprecated --smoke-note was provided and ignored for scoring."]
|
|
3354
|
+
: [];
|
|
3355
|
+
if (!smokeResult) {
|
|
3356
|
+
return {
|
|
3357
|
+
id: "local-smoke",
|
|
3358
|
+
category: "smoke",
|
|
3359
|
+
level: "warn",
|
|
3360
|
+
status: "warn",
|
|
3361
|
+
points: 0,
|
|
3362
|
+
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
3363
|
+
message: "Measured local smoke was not run.",
|
|
3364
|
+
remediation:
|
|
3365
|
+
"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.",
|
|
3366
|
+
evidence: deprecatedEvidence,
|
|
3367
|
+
};
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
const evidence = [
|
|
3371
|
+
`/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
|
|
3372
|
+
...smokeResult.operations.map(
|
|
3373
|
+
(outcome) =>
|
|
3374
|
+
`${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
|
|
3375
|
+
),
|
|
3376
|
+
...deprecatedEvidence,
|
|
3377
|
+
];
|
|
3378
|
+
const incoherent = smokeResult.operations.filter((outcome) => outcome.status === "incoherent");
|
|
3379
|
+
if (!smokeResult.healthOk || smokeResult.bootError || incoherent.length > 0) {
|
|
3380
|
+
return {
|
|
3381
|
+
id: "local-smoke",
|
|
3382
|
+
category: "smoke",
|
|
3383
|
+
level: "blocker",
|
|
3384
|
+
status: "fail",
|
|
3385
|
+
points: 0,
|
|
3386
|
+
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
3387
|
+
message: "Measured smoke failed to verify a coherent provider runtime.",
|
|
3388
|
+
remediation:
|
|
3389
|
+
"Fix the dev server boot, `/health`, or incoherent operation responses, then rerun `bun run submit-check -- --smoke`.",
|
|
3390
|
+
evidence: smokeResult.bootError ? [`boot: ${smokeResult.bootError}`, ...evidence] : evidence,
|
|
3391
|
+
details: smokeResult,
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
|
|
3396
|
+
if (successes.length > 0) {
|
|
2160
3397
|
return {
|
|
2161
3398
|
id: "local-smoke",
|
|
2162
3399
|
category: "smoke",
|
|
@@ -2164,8 +3401,9 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
|
|
|
2164
3401
|
status: "pass",
|
|
2165
3402
|
points: CATEGORY_MAX_POINTS.smoke,
|
|
2166
3403
|
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
2167
|
-
message: "
|
|
2168
|
-
evidence
|
|
3404
|
+
message: "Measured smoke passed with at least one schema-valid operation success.",
|
|
3405
|
+
evidence,
|
|
3406
|
+
details: smokeResult,
|
|
2169
3407
|
};
|
|
2170
3408
|
}
|
|
2171
3409
|
|
|
@@ -2174,14 +3412,226 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
|
|
|
2174
3412
|
category: "smoke",
|
|
2175
3413
|
level: "warn",
|
|
2176
3414
|
status: "warn",
|
|
2177
|
-
points:
|
|
3415
|
+
points: 7,
|
|
2178
3416
|
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
2179
|
-
message: "
|
|
3417
|
+
message: "Runtime path was verified, but no live upstream schema-valid success was observed.",
|
|
2180
3418
|
remediation:
|
|
2181
|
-
"
|
|
3419
|
+
"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.",
|
|
3420
|
+
evidence,
|
|
3421
|
+
details: smokeResult,
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
export async function runSubmitCheckSmoke(
|
|
3426
|
+
providerRoot: string,
|
|
3427
|
+
provider?: ProviderDefinition,
|
|
3428
|
+
): Promise<SmokeResult> {
|
|
3429
|
+
const loadedProvider = provider ?? (await loadProvider(providerRoot));
|
|
3430
|
+
if (!loadedProvider) {
|
|
3431
|
+
return {
|
|
3432
|
+
measured: true,
|
|
3433
|
+
healthOk: false,
|
|
3434
|
+
bootError: "Provider could not be loaded.",
|
|
3435
|
+
operations: [],
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
const port = await getAvailablePort();
|
|
3440
|
+
const server = spawn("bun", ["run", "dev"], {
|
|
3441
|
+
cwd: providerRoot,
|
|
3442
|
+
env: { ...process.env, APIFUSE__RUNTIME__PORT: String(port) },
|
|
3443
|
+
detached: process.platform !== "win32",
|
|
3444
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3445
|
+
});
|
|
3446
|
+
let output = "";
|
|
3447
|
+
server.stdout?.on("data", (chunk) => {
|
|
3448
|
+
output += chunk.toString();
|
|
3449
|
+
});
|
|
3450
|
+
server.stderr?.on("data", (chunk) => {
|
|
3451
|
+
output += chunk.toString();
|
|
3452
|
+
});
|
|
3453
|
+
|
|
3454
|
+
try {
|
|
3455
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
3456
|
+
const health = await waitForSmokeHealth(`${baseUrl}/health`, server, () => output);
|
|
3457
|
+
if (!health.ok) {
|
|
3458
|
+
return {
|
|
3459
|
+
measured: true,
|
|
3460
|
+
healthOk: false,
|
|
3461
|
+
bootError: health.error,
|
|
3462
|
+
operations: [],
|
|
3463
|
+
};
|
|
3464
|
+
}
|
|
3465
|
+
const operations: SmokeOperationOutcome[] = [];
|
|
3466
|
+
for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
|
|
3467
|
+
operations.push(
|
|
3468
|
+
await smokeOperation(baseUrl, operationId, operation.output, {
|
|
3469
|
+
requestId: `req_submit_check_smoke_${operationId}`,
|
|
3470
|
+
input: operation.fixtures?.request ?? {},
|
|
3471
|
+
headers: {},
|
|
3472
|
+
}),
|
|
3473
|
+
);
|
|
3474
|
+
}
|
|
3475
|
+
return { measured: true, healthOk: true, operations };
|
|
3476
|
+
} finally {
|
|
3477
|
+
await stopSmokeServer(server);
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
async function smokeOperation(
|
|
3482
|
+
baseUrl: string,
|
|
3483
|
+
operationId: string,
|
|
3484
|
+
outputSchema: ProviderDefinition["operations"][string]["output"],
|
|
3485
|
+
body: unknown,
|
|
3486
|
+
): Promise<SmokeOperationOutcome> {
|
|
3487
|
+
try {
|
|
3488
|
+
const response = await fetch(`${baseUrl}/v1/${operationId}`, {
|
|
3489
|
+
method: "POST",
|
|
3490
|
+
headers: { "content-type": "application/json" },
|
|
3491
|
+
body: JSON.stringify(body),
|
|
3492
|
+
signal: AbortSignal.timeout(20_000),
|
|
3493
|
+
});
|
|
3494
|
+
const payload = await response.json().catch(() => undefined);
|
|
3495
|
+
if (response.ok && isRecord(payload) && "data" in payload) {
|
|
3496
|
+
const parsed = safeParseSchemaSync(
|
|
3497
|
+
outputSchema,
|
|
3498
|
+
payload.data,
|
|
3499
|
+
`operations.${operationId}.output`,
|
|
3500
|
+
);
|
|
3501
|
+
if (parsed.success) {
|
|
3502
|
+
return {
|
|
3503
|
+
operationId,
|
|
3504
|
+
status: "success",
|
|
3505
|
+
httpStatus: response.status,
|
|
3506
|
+
message: "schema-valid success",
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
3509
|
+
return {
|
|
3510
|
+
operationId,
|
|
3511
|
+
status: "incoherent",
|
|
3512
|
+
httpStatus: response.status,
|
|
3513
|
+
message: "success payload failed output schema validation",
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
if (isStructuredProviderError(payload) && response.status < 500) {
|
|
3517
|
+
return {
|
|
3518
|
+
operationId,
|
|
3519
|
+
status: "structured_error",
|
|
3520
|
+
httpStatus: response.status,
|
|
3521
|
+
message: `${payload.error.code}: ${payload.error.message}`,
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3524
|
+
return {
|
|
3525
|
+
operationId,
|
|
3526
|
+
status: "incoherent",
|
|
3527
|
+
httpStatus: response.status,
|
|
3528
|
+
message: isStructuredProviderError(payload)
|
|
3529
|
+
? `${payload.error.code}: ${payload.error.message}`
|
|
3530
|
+
: "response was not a schema-valid success or structured provider error",
|
|
3531
|
+
};
|
|
3532
|
+
} catch (error) {
|
|
3533
|
+
return {
|
|
3534
|
+
operationId,
|
|
3535
|
+
status: "incoherent",
|
|
3536
|
+
message: error instanceof Error ? error.message : String(error),
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
function isStructuredProviderError(
|
|
3542
|
+
value: unknown,
|
|
3543
|
+
): value is { error: { code: string; message: string } } {
|
|
3544
|
+
return (
|
|
3545
|
+
isRecord(value) &&
|
|
3546
|
+
isRecord(value.error) &&
|
|
3547
|
+
typeof value.error.code === "string" &&
|
|
3548
|
+
typeof value.error.message === "string"
|
|
3549
|
+
);
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
async function getAvailablePort(): Promise<number> {
|
|
3553
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
3554
|
+
const server = createServer();
|
|
3555
|
+
server.once("error", rejectPromise);
|
|
3556
|
+
server.listen(0, "127.0.0.1", () => {
|
|
3557
|
+
const address = server.address();
|
|
3558
|
+
server.close((error) => {
|
|
3559
|
+
if (error) {
|
|
3560
|
+
rejectPromise(error);
|
|
3561
|
+
return;
|
|
3562
|
+
}
|
|
3563
|
+
if (!address || typeof address === "string") {
|
|
3564
|
+
rejectPromise(new Error("Could not allocate a local TCP port."));
|
|
3565
|
+
return;
|
|
3566
|
+
}
|
|
3567
|
+
resolvePromise(address.port);
|
|
3568
|
+
});
|
|
3569
|
+
});
|
|
3570
|
+
});
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
async function waitForSmokeHealth(
|
|
3574
|
+
url: string,
|
|
3575
|
+
server: ChildProcess,
|
|
3576
|
+
getOutput: () => string,
|
|
3577
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
3578
|
+
const deadline = Date.now() + 20_000;
|
|
3579
|
+
let lastError: unknown;
|
|
3580
|
+
|
|
3581
|
+
while (Date.now() < deadline) {
|
|
3582
|
+
if (server.exitCode !== null) {
|
|
3583
|
+
return {
|
|
3584
|
+
ok: false,
|
|
3585
|
+
error: `Dev server exited early with code ${server.exitCode}. ${getOutput()}`,
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
try {
|
|
3590
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
|
|
3591
|
+
if (response.ok) return { ok: true };
|
|
3592
|
+
lastError = new Error(`${url} returned ${response.status}`);
|
|
3593
|
+
} catch (error) {
|
|
3594
|
+
lastError = error;
|
|
3595
|
+
}
|
|
3596
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
return {
|
|
3600
|
+
ok: false,
|
|
3601
|
+
error: `Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}. ${getOutput()}`,
|
|
2182
3602
|
};
|
|
2183
3603
|
}
|
|
2184
3604
|
|
|
3605
|
+
async function stopSmokeServer(server: ChildProcess): Promise<void> {
|
|
3606
|
+
if (server.exitCode !== null) return;
|
|
3607
|
+
killSmokeProcessTree(server, "SIGTERM");
|
|
3608
|
+
await new Promise<void>((resolvePromise) => {
|
|
3609
|
+
const timeout = setTimeout(() => {
|
|
3610
|
+
if (server.exitCode === null) {
|
|
3611
|
+
killSmokeProcessTree(server, "SIGKILL");
|
|
3612
|
+
}
|
|
3613
|
+
resolvePromise();
|
|
3614
|
+
}, 2_000);
|
|
3615
|
+
server.once("exit", () => {
|
|
3616
|
+
clearTimeout(timeout);
|
|
3617
|
+
resolvePromise();
|
|
3618
|
+
});
|
|
3619
|
+
});
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
function killSmokeProcessTree(server: ChildProcess, signal: NodeJS.Signals): void {
|
|
3623
|
+
if (server.pid === undefined) return;
|
|
3624
|
+
try {
|
|
3625
|
+
if (process.platform === "win32") {
|
|
3626
|
+
server.kill(signal);
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3629
|
+
process.kill(-server.pid, signal);
|
|
3630
|
+
} catch {
|
|
3631
|
+
server.kill(signal);
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
|
|
2185
3635
|
function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
2186
3636
|
const authMode = provider.auth?.mode ?? "none";
|
|
2187
3637
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
@@ -2205,7 +3655,7 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
|
2205
3655
|
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
2206
3656
|
message: "OAuth auth mode does not declare persisted credential.keys.",
|
|
2207
3657
|
remediation:
|
|
2208
|
-
"
|
|
3658
|
+
"Add `credential: { keys: [...] }` to `defineProvider` with the persisted OAuth token fields returned by the real token exchange.",
|
|
2209
3659
|
};
|
|
2210
3660
|
}
|
|
2211
3661
|
|
|
@@ -2221,10 +3671,8 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
|
2221
3671
|
status: "warn",
|
|
2222
3672
|
points: 7,
|
|
2223
3673
|
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
2224
|
-
message:
|
|
2225
|
-
|
|
2226
|
-
remediation:
|
|
2227
|
-
"Confirm auth.mode and operation annotations match the actual upstream auth model.",
|
|
3674
|
+
message: "Provider is no-auth but at least one operation is not marked openWorld.",
|
|
3675
|
+
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(", ")}.`,
|
|
2228
3676
|
evidence: securedOperations.map(([operationId]) => operationId),
|
|
2229
3677
|
};
|
|
2230
3678
|
}
|
|
@@ -2266,9 +3714,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
2266
3714
|
|
|
2267
3715
|
const points = Math.max(
|
|
2268
3716
|
0,
|
|
2269
|
-
CATEGORY_MAX_POINTS.docs -
|
|
2270
|
-
missing.length * 2 -
|
|
2271
|
-
(mentionsSubmitCheck ? 0 : 1),
|
|
3717
|
+
CATEGORY_MAX_POINTS.docs - missing.length * 2 - (mentionsSubmitCheck ? 0 : 1),
|
|
2272
3718
|
);
|
|
2273
3719
|
|
|
2274
3720
|
return [
|
|
@@ -2285,7 +3731,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
2285
3731
|
: "Provider README includes expected submission guidance.",
|
|
2286
3732
|
remediation:
|
|
2287
3733
|
missing.length > 0 || !mentionsSubmitCheck
|
|
2288
|
-
? "
|
|
3734
|
+
? "Update `README.md` to include `Parameters`, `Response`, `Example`, and submit-check evidence guidance sections."
|
|
2289
3735
|
: undefined,
|
|
2290
3736
|
evidence: [
|
|
2291
3737
|
...missing.map(([, label]) => `missing ${label}`),
|
|
@@ -2295,9 +3741,48 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
2295
3741
|
];
|
|
2296
3742
|
}
|
|
2297
3743
|
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
3744
|
+
// Splits secret findings into still-active findings and acknowledged
|
|
3745
|
+
// `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
|
|
3746
|
+
// (same pragma placement: the finding line or the line directly above it).
|
|
3747
|
+
// Every finding source carries a line number (entropy candidates and located
|
|
3748
|
+
// SECRET_PATTERNS matches); a finding that somehow lacks one stays active
|
|
3749
|
+
// defensively.
|
|
3750
|
+
function partitionSecretScanAllowOverrides(
|
|
3751
|
+
providerRoot: string,
|
|
3752
|
+
findings: readonly SecretFinding[],
|
|
3753
|
+
): { active: SecretFinding[]; overridden: SecretFinding[] } {
|
|
3754
|
+
const fileLineCache = new Map<string, string[]>();
|
|
3755
|
+
const active: SecretFinding[] = [];
|
|
3756
|
+
const overridden: SecretFinding[] = [];
|
|
3757
|
+
|
|
3758
|
+
for (const finding of findings) {
|
|
3759
|
+
if (finding.line === undefined) {
|
|
3760
|
+
active.push(finding);
|
|
3761
|
+
continue;
|
|
3762
|
+
}
|
|
3763
|
+
const absolute = resolve(providerRoot, finding.file);
|
|
3764
|
+
let lines = fileLineCache.get(absolute);
|
|
3765
|
+
if (lines === undefined) {
|
|
3766
|
+
lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
|
|
3767
|
+
fileLineCache.set(absolute, lines);
|
|
3768
|
+
}
|
|
3769
|
+
if (hasAllowOverride(lines, finding.line, "secret-scan")) {
|
|
3770
|
+
overridden.push(finding);
|
|
3771
|
+
} else {
|
|
3772
|
+
active.push(finding);
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
return { active, overridden };
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
|
|
3780
|
+
const { active, overridden } = partitionSecretScanAllowOverrides(
|
|
3781
|
+
providerRoot,
|
|
3782
|
+
findSecretFindings(providerRoot, provider?.id),
|
|
3783
|
+
);
|
|
3784
|
+
const blockerFindings = active.filter((finding) => finding.level !== "warn");
|
|
3785
|
+
if (blockerFindings.length > 0) {
|
|
2301
3786
|
return {
|
|
2302
3787
|
id: "secret-scan",
|
|
2303
3788
|
category: "security",
|
|
@@ -2305,11 +3790,41 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
|
2305
3790
|
status: "fail",
|
|
2306
3791
|
points: 0,
|
|
2307
3792
|
maxPoints: CATEGORY_MAX_POINTS.security,
|
|
2308
|
-
message:
|
|
2309
|
-
|
|
3793
|
+
message: "Potential real credential material was found in shareable files.",
|
|
3794
|
+
remediation:
|
|
3795
|
+
blockerFindings[0]?.remediation ??
|
|
3796
|
+
'Move hardcoded credentials to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential.',
|
|
3797
|
+
evidence: blockerFindings.map(
|
|
3798
|
+
(finding) =>
|
|
3799
|
+
finding.evidence ??
|
|
3800
|
+
`${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
|
|
3801
|
+
),
|
|
3802
|
+
};
|
|
3803
|
+
}
|
|
3804
|
+
if (active.length > 0 || overridden.length > 0) {
|
|
3805
|
+
const messageBase =
|
|
3806
|
+
active.length > 0
|
|
3807
|
+
? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
|
|
3808
|
+
: "Potential credential-like strings were found in shareable files.";
|
|
3809
|
+
const message =
|
|
3810
|
+
overridden.length > 0
|
|
3811
|
+
? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
|
|
3812
|
+
: messageBase;
|
|
3813
|
+
return {
|
|
3814
|
+
id: "secret-scan",
|
|
3815
|
+
category: "security",
|
|
3816
|
+
level: "warn",
|
|
3817
|
+
status: "warn",
|
|
3818
|
+
points: 8,
|
|
3819
|
+
maxPoints: CATEGORY_MAX_POINTS.security,
|
|
3820
|
+
message,
|
|
2310
3821
|
remediation:
|
|
2311
|
-
|
|
2312
|
-
evidence:
|
|
3822
|
+
'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>`.',
|
|
3823
|
+
evidence: [...active, ...overridden].map(
|
|
3824
|
+
(finding) =>
|
|
3825
|
+
finding.evidence ??
|
|
3826
|
+
`${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
|
|
3827
|
+
),
|
|
2313
3828
|
};
|
|
2314
3829
|
}
|
|
2315
3830
|
|
|
@@ -2321,7 +3836,7 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
|
2321
3836
|
);
|
|
2322
3837
|
}
|
|
2323
3838
|
|
|
2324
|
-
function findSecretFindings(providerRoot: string): SecretFinding[] {
|
|
3839
|
+
function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFinding[] {
|
|
2325
3840
|
const candidateFiles = [
|
|
2326
3841
|
"README.md",
|
|
2327
3842
|
"index.ts",
|
|
@@ -2336,20 +3851,343 @@ function findSecretFindings(providerRoot: string): SecretFinding[] {
|
|
|
2336
3851
|
if (!existsSync(filePath)) continue;
|
|
2337
3852
|
const content = readFileSync(filePath, "utf8");
|
|
2338
3853
|
for (const [label, pattern] of SECRET_PATTERNS) {
|
|
2339
|
-
|
|
2340
|
-
|
|
3854
|
+
// Locate every match to its line so pattern findings carry the line
|
|
3855
|
+
// information hasAllowOverride needs: `// @apifuse-allow secret-scan`
|
|
3856
|
+
// must behave uniformly across entropy findings and pattern findings.
|
|
3857
|
+
const globalPattern = new RegExp(
|
|
3858
|
+
pattern.source,
|
|
3859
|
+
pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
|
|
3860
|
+
);
|
|
3861
|
+
const seenLines = new Set<number>();
|
|
3862
|
+
for (const match of content.matchAll(globalPattern)) {
|
|
3863
|
+
const line = offsetToLine(content, match.index);
|
|
3864
|
+
if (seenLines.has(line)) continue;
|
|
3865
|
+
seenLines.add(line);
|
|
3866
|
+
findings.push({ label, file: relativePath, line });
|
|
2341
3867
|
}
|
|
2342
3868
|
}
|
|
2343
3869
|
}
|
|
2344
3870
|
|
|
3871
|
+
findings.push(...findEntropySecretFindings(providerRoot, providerId));
|
|
3872
|
+
return findings;
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
function findEntropySecretFindings(providerRoot: string, providerId: string): SecretFinding[] {
|
|
3876
|
+
const findings: SecretFinding[] = [];
|
|
3877
|
+
for (const filePath of listNonTestProviderSourceFiles(providerRoot)) {
|
|
3878
|
+
const relativePath = toRelativeProviderPath(providerRoot, filePath);
|
|
3879
|
+
if (isEntropySecretExcludedPath(relativePath)) continue;
|
|
3880
|
+
const content = readFileSync(filePath, "utf8");
|
|
3881
|
+
const lines = content.split(/\r?\n/);
|
|
3882
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
3883
|
+
const line = lines[lineIndex] ?? "";
|
|
3884
|
+
for (const candidate of extractStringLiteralCandidates(line)) {
|
|
3885
|
+
const finding = classifyEntropyCandidate({
|
|
3886
|
+
value: candidate,
|
|
3887
|
+
line,
|
|
3888
|
+
file: relativePath,
|
|
3889
|
+
lineNumber: lineIndex + 1,
|
|
3890
|
+
providerId,
|
|
3891
|
+
});
|
|
3892
|
+
if (finding) findings.push(finding);
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
2345
3896
|
return findings;
|
|
2346
3897
|
}
|
|
2347
3898
|
|
|
3899
|
+
function isEntropySecretExcludedPath(relativePath: string): boolean {
|
|
3900
|
+
return (
|
|
3901
|
+
relativePath.endsWith(".test.ts") ||
|
|
3902
|
+
relativePath.startsWith("__tests__/") ||
|
|
3903
|
+
relativePath.includes("/__tests__/") ||
|
|
3904
|
+
relativePath.startsWith("__fixtures__/") ||
|
|
3905
|
+
relativePath.includes("/__fixtures__/")
|
|
3906
|
+
);
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
export function extractStringLiteralCandidates(line: string): string[] {
|
|
3910
|
+
const candidates: string[] = [];
|
|
3911
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
3912
|
+
const quote = line[index];
|
|
3913
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") continue;
|
|
3914
|
+
|
|
3915
|
+
const contentStart = index + 1;
|
|
3916
|
+
let cursor = contentStart;
|
|
3917
|
+
while (cursor < line.length) {
|
|
3918
|
+
const char = line[cursor];
|
|
3919
|
+
if (char === "\\") {
|
|
3920
|
+
cursor += 2;
|
|
3921
|
+
continue;
|
|
3922
|
+
}
|
|
3923
|
+
if (char === quote) {
|
|
3924
|
+
if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
|
|
3925
|
+
candidates.push(line.slice(contentStart, cursor));
|
|
3926
|
+
}
|
|
3927
|
+
index = cursor;
|
|
3928
|
+
break;
|
|
3929
|
+
}
|
|
3930
|
+
cursor += 1;
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
return candidates;
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
function classifyEntropyCandidate(input: {
|
|
3937
|
+
value: string;
|
|
3938
|
+
line: string;
|
|
3939
|
+
file: string;
|
|
3940
|
+
lineNumber: number;
|
|
3941
|
+
providerId: string;
|
|
3942
|
+
}): SecretFinding | undefined {
|
|
3943
|
+
const value = input.value;
|
|
3944
|
+
if (!shouldConsiderEntropyValue(value)) return undefined;
|
|
3945
|
+
const charset = classifyEntropyCharset(value);
|
|
3946
|
+
if (!charset) return undefined;
|
|
3947
|
+
const entropy = shannonEntropy(value);
|
|
3948
|
+
const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
|
|
3949
|
+
// Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
|
|
3950
|
+
// "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
|
|
3951
|
+
// (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
|
|
3952
|
+
// blocker-flagged. They are never skipped — entropy classification always
|
|
3953
|
+
// runs — but when the secret-ish context comes solely from identifier-
|
|
3954
|
+
// constant-shaped literal text (the line with those literals stripped
|
|
3955
|
+
// carries no secret-ish identifier), the finding is capped at a
|
|
3956
|
+
// non-blocking warning instead of a blocker. Stripping constant-shaped
|
|
3957
|
+
// siblings — not just the candidate — matters for lines holding several
|
|
3958
|
+
// constants (e.g. an ERROR_CODES array), while quoted property keys and
|
|
3959
|
+
// header names ("Authorization", "apiKey") stay visible as genuine
|
|
3960
|
+
// external context. Assignments to `apiKey`/`token`/`secret`-style names
|
|
3961
|
+
// still escalate to blockers via the identifier side, and
|
|
3962
|
+
// `// @apifuse-allow secret-scan` remains the reviewed way to silence the
|
|
3963
|
+
// warning.
|
|
3964
|
+
const selfContextOnlyConstant =
|
|
3965
|
+
secretishContext &&
|
|
3966
|
+
isScreamingSnakeConstantValue(value) &&
|
|
3967
|
+
!SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
|
|
3968
|
+
const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
|
|
3969
|
+
if (entropy < threshold) return undefined;
|
|
3970
|
+
|
|
3971
|
+
const preview = `${value.slice(0, 4)}...[REDACTED length=${value.length}]`;
|
|
3972
|
+
const envName = `APIFUSE__PROVIDER__${input.providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}__${guessSecretName(input.line)}`;
|
|
3973
|
+
const location = `${input.file}:${input.lineNumber}`;
|
|
3974
|
+
const label =
|
|
3975
|
+
charset === "hex"
|
|
3976
|
+
? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
|
|
3977
|
+
: `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
|
|
3978
|
+
const contextNote = selfContextOnlyConstant
|
|
3979
|
+
? "; identifier-like constant (downgraded to warning)"
|
|
3980
|
+
: secretishContext
|
|
3981
|
+
? ""
|
|
3982
|
+
: "; may be a false positive";
|
|
3983
|
+
return {
|
|
3984
|
+
label,
|
|
3985
|
+
file: input.file,
|
|
3986
|
+
line: input.lineNumber,
|
|
3987
|
+
level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
|
|
3988
|
+
remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
|
|
3989
|
+
evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
|
|
3990
|
+
};
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
// Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
|
|
3994
|
+
// separated segments, each essentially pure alphabetic — letters optionally
|
|
3995
|
+
// followed by a SHORT digit suffix (at most 2, e.g. version markers like
|
|
3996
|
+
// "V2") — and at most 15% digits across the whole value. Dictionary-style
|
|
3997
|
+
// constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
|
|
3998
|
+
// "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
|
|
3999
|
+
// (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
|
|
4000
|
+
// uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
|
|
4001
|
+
// ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
|
|
4002
|
+
// it only decides whether a finding whose secret-ish context comes solely
|
|
4003
|
+
// from the literal's own text is downgraded from blocker to warning, so it
|
|
4004
|
+
// deliberately stays strict: values that merely contain a secret-ish word but
|
|
4005
|
+
// are not word-like constants keep full blocker severity.
|
|
4006
|
+
function isScreamingSnakeConstantValue(value: string): boolean {
|
|
4007
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
|
|
4008
|
+
const segments = value.split("_");
|
|
4009
|
+
if (segments.length < 2) return false;
|
|
4010
|
+
if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
|
|
4011
|
+
const digitCount = value.match(/[0-9]/g)?.length ?? 0;
|
|
4012
|
+
return digitCount / value.length <= 0.15;
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
type LineStringLiteral = {
|
|
4016
|
+
// Index of the opening quote.
|
|
4017
|
+
start: number;
|
|
4018
|
+
// Index just past the closing quote (line end when unterminated).
|
|
4019
|
+
end: number;
|
|
4020
|
+
content: string;
|
|
4021
|
+
closed: boolean;
|
|
4022
|
+
role: "key" | "value";
|
|
4023
|
+
// Nearest unclosed bracket enclosing the literal's start, if any.
|
|
4024
|
+
container?: { bracket: "[" | "(" | "{"; index: number };
|
|
4025
|
+
};
|
|
4026
|
+
|
|
4027
|
+
// Stable identity for the container a literal sits in ("top" when the
|
|
4028
|
+
// literal is not inside any bracket on the line).
|
|
4029
|
+
function literalContainerKey(literal: LineStringLiteral): string {
|
|
4030
|
+
return literal.container
|
|
4031
|
+
? `${literal.container.bracket}${literal.container.index}`
|
|
4032
|
+
: "top";
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
// Single-pass line tokenizer: extracts every string literal with its span and
|
|
4036
|
+
// classifies its syntactic role once. A literal is a KEY when it is preceded
|
|
4037
|
+
// (ignoring whitespace) by "{", ",", "(", or the line start AND followed
|
|
4038
|
+
// (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
|
|
4039
|
+
// other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
|
|
4040
|
+
// elements, call arguments, and assignment right-hand sides, even when a
|
|
4041
|
+
// ternary's ":" happens to follow them. Uses the same quote/escape walking as
|
|
4042
|
+
// extractStringLiteralCandidates.
|
|
4043
|
+
function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
|
|
4044
|
+
const literals: LineStringLiteral[] = [];
|
|
4045
|
+
const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
|
|
4046
|
+
let index = 0;
|
|
4047
|
+
while (index < line.length) {
|
|
4048
|
+
const char = line[index];
|
|
4049
|
+
if (char !== '"' && char !== "'" && char !== "`") {
|
|
4050
|
+
if (char === "[" || char === "(" || char === "{") {
|
|
4051
|
+
bracketStack.push({ bracket: char, index });
|
|
4052
|
+
} else if (char === "]" || char === ")" || char === "}") {
|
|
4053
|
+
bracketStack.pop();
|
|
4054
|
+
}
|
|
4055
|
+
index += 1;
|
|
4056
|
+
continue;
|
|
4057
|
+
}
|
|
4058
|
+
const quote = char;
|
|
4059
|
+
const start = index;
|
|
4060
|
+
const contentStart = index + 1;
|
|
4061
|
+
let cursor = contentStart;
|
|
4062
|
+
let closed = false;
|
|
4063
|
+
while (cursor < line.length) {
|
|
4064
|
+
const inner = line[cursor];
|
|
4065
|
+
if (inner === "\\") {
|
|
4066
|
+
cursor += 2;
|
|
4067
|
+
continue;
|
|
4068
|
+
}
|
|
4069
|
+
if (inner === quote) {
|
|
4070
|
+
closed = true;
|
|
4071
|
+
break;
|
|
4072
|
+
}
|
|
4073
|
+
cursor += 1;
|
|
4074
|
+
}
|
|
4075
|
+
const contentEnd = Math.min(cursor, line.length);
|
|
4076
|
+
const end = closed ? cursor + 1 : line.length;
|
|
4077
|
+
const before = line.slice(0, start).trimEnd();
|
|
4078
|
+
const keyPreceded = before === "" || /[{,(]$/.test(before);
|
|
4079
|
+
const keyFollowed = closed && /^\s*:/.test(line.slice(end));
|
|
4080
|
+
literals.push({
|
|
4081
|
+
start,
|
|
4082
|
+
end,
|
|
4083
|
+
content: line.slice(contentStart, contentEnd),
|
|
4084
|
+
closed,
|
|
4085
|
+
role: keyPreceded && keyFollowed ? "key" : "value",
|
|
4086
|
+
container: bracketStack[bracketStack.length - 1],
|
|
4087
|
+
});
|
|
4088
|
+
index = end;
|
|
4089
|
+
}
|
|
4090
|
+
return literals;
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
// Builds the context text used to decide whether a candidate's secret-ish
|
|
4094
|
+
// context is genuine. The candidate's own literal is ALWAYS stripped
|
|
4095
|
+
// (self-context rule). SIBLING identifier-constant-shaped candidate literals
|
|
4096
|
+
// (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
|
|
4097
|
+
// stripped only when they share the candidate's non-call container — the same
|
|
4098
|
+
// `[...]` array, the same `{...}` object value list, or the bracket-free top
|
|
4099
|
+
// level (ternary arms) — so a value list of error codes cannot poison its own
|
|
4100
|
+
// members' context. Call-argument siblings (inside `(...)`) always keep their
|
|
4101
|
+
// context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
|
|
4102
|
+
// argument genuinely describes the second, so stripping it would erase real
|
|
4103
|
+
// auth/token context. KEY-role literals are never stripped.
|
|
4104
|
+
function stripIdentifierConstantLiterals(line: string, candidate: string): string {
|
|
4105
|
+
const literals = tokenizeLineStringLiterals(line);
|
|
4106
|
+
const candidateContainers = new Set<string>();
|
|
4107
|
+
for (const literal of literals) {
|
|
4108
|
+
if (literal.content === candidate) {
|
|
4109
|
+
candidateContainers.add(literalContainerKey(literal));
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
let result = "";
|
|
4113
|
+
let previousEnd = 0;
|
|
4114
|
+
for (const literal of literals) {
|
|
4115
|
+
result += line.slice(previousEnd, literal.start);
|
|
4116
|
+
const isSelf = literal.content === candidate;
|
|
4117
|
+
const isSameNonCallContainerSibling =
|
|
4118
|
+
literal.role === "value" &&
|
|
4119
|
+
literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
|
|
4120
|
+
isScreamingSnakeConstantValue(literal.content) &&
|
|
4121
|
+
literal.container?.bracket !== "(" &&
|
|
4122
|
+
candidateContainers.has(literalContainerKey(literal));
|
|
4123
|
+
if (isSelf || isSameNonCallContainerSibling) {
|
|
4124
|
+
const quote = line[literal.start] ?? "";
|
|
4125
|
+
result += quote + (literal.closed ? quote : "");
|
|
4126
|
+
} else {
|
|
4127
|
+
result += line.slice(literal.start, literal.end);
|
|
4128
|
+
}
|
|
4129
|
+
previousEnd = literal.end;
|
|
4130
|
+
}
|
|
4131
|
+
result += line.slice(previousEnd);
|
|
4132
|
+
return result;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
function shouldConsiderEntropyValue(value: string): boolean {
|
|
4136
|
+
const lower = value.toLowerCase();
|
|
4137
|
+
if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
|
|
4138
|
+
return false;
|
|
4139
|
+
}
|
|
4140
|
+
if (/^sha(?:256|512)-/i.test(value)) return false;
|
|
4141
|
+
if (/\s/.test(value)) return false;
|
|
4142
|
+
if (value.includes("${")) return false;
|
|
4143
|
+
if (value.includes("/")) return false;
|
|
4144
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
|
|
4145
|
+
if (/^(?:\.{0,2}\/|~\/|[A-Za-z]:\\)/.test(value)) return false;
|
|
4146
|
+
if (value.includes(".") && /^[A-Za-z0-9_.-]+$/.test(value)) return false;
|
|
4147
|
+
if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
|
|
4148
|
+
return false;
|
|
4149
|
+
}
|
|
4150
|
+
return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
|
|
4154
|
+
if (/^[a-f0-9]+$/i.test(value) && value.length >= 32) return "hex";
|
|
4155
|
+
const base64ishChars = value.match(/[A-Za-z0-9+/=_-]/g)?.length ?? 0;
|
|
4156
|
+
if (base64ishChars / value.length >= 0.9) return "base64";
|
|
4157
|
+
return undefined;
|
|
4158
|
+
}
|
|
4159
|
+
|
|
4160
|
+
function shannonEntropy(value: string): number {
|
|
4161
|
+
const counts = new Map<string, number>();
|
|
4162
|
+
for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
4163
|
+
let entropy = 0;
|
|
4164
|
+
for (const count of counts.values()) {
|
|
4165
|
+
const probability = count / value.length;
|
|
4166
|
+
entropy -= probability * Math.log2(probability);
|
|
4167
|
+
}
|
|
4168
|
+
return entropy;
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4171
|
+
function guessSecretName(line: string): string {
|
|
4172
|
+
const match =
|
|
4173
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
|
|
4174
|
+
/["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
|
|
4175
|
+
const raw = match?.[1] ?? "SECRET";
|
|
4176
|
+
return raw
|
|
4177
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
4178
|
+
.toUpperCase()
|
|
4179
|
+
.replace(/[^A-Z0-9]+/g, "_");
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
|
|
4183
|
+
|
|
4184
|
+
// Minimum length for a string literal to be considered an entropy candidate.
|
|
4185
|
+
// Shared by candidate extraction, entropy screening, and the context strip so
|
|
4186
|
+
// the three stay coherent.
|
|
4187
|
+
const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
|
|
4188
|
+
|
|
2348
4189
|
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
2349
|
-
[
|
|
2350
|
-
"JWT-like token",
|
|
2351
|
-
/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/,
|
|
2352
|
-
],
|
|
4190
|
+
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
2353
4191
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
|
2354
4192
|
["Stripe live key", /(?:sk|rk)_live_[A-Za-z0-9]{20,}/],
|
|
2355
4193
|
["Bearer token", /Bearer\s+[A-Za-z0-9._~+/=-]{32,}/i],
|
|
@@ -2359,9 +4197,7 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
|
2359
4197
|
],
|
|
2360
4198
|
];
|
|
2361
4199
|
|
|
2362
|
-
async function safeLoadProvider(
|
|
2363
|
-
providerRoot: string,
|
|
2364
|
-
): Promise<ProviderDefinition | undefined> {
|
|
4200
|
+
async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|
|
2365
4201
|
try {
|
|
2366
4202
|
return await loadProvider(providerRoot);
|
|
2367
4203
|
} catch {
|
|
@@ -2369,9 +4205,7 @@ async function safeLoadProvider(
|
|
|
2369
4205
|
}
|
|
2370
4206
|
}
|
|
2371
4207
|
|
|
2372
|
-
async function loadProvider(
|
|
2373
|
-
providerRoot: string,
|
|
2374
|
-
): Promise<ProviderDefinition | undefined> {
|
|
4208
|
+
async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|
|
2375
4209
|
const entryPath = resolve(providerRoot, "index.ts");
|
|
2376
4210
|
if (!existsSync(entryPath)) {
|
|
2377
4211
|
return undefined;
|
|
@@ -2451,8 +4285,7 @@ export function renderText(report: SubmitCheckReport): string {
|
|
|
2451
4285
|
];
|
|
2452
4286
|
|
|
2453
4287
|
for (const check of report.checks) {
|
|
2454
|
-
const marker =
|
|
2455
|
-
check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
|
|
4288
|
+
const marker = check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
|
|
2456
4289
|
lines.push(
|
|
2457
4290
|
`${marker} [${check.category}] ${check.message} (${check.points}/${check.maxPoints})`,
|
|
2458
4291
|
);
|
|
@@ -2474,9 +4307,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
|
|
|
2474
4307
|
`- **Provider**: ${report.provider.id}@${report.provider.version}`,
|
|
2475
4308
|
`- **SDK**: ${report.provider.sdkVersion}`,
|
|
2476
4309
|
`- **Runtime/Auth**: ${report.provider.runtime} / ${report.provider.authMode}`,
|
|
2477
|
-
...(report.provider.tier
|
|
2478
|
-
? [`- **Bounty tier**: ${report.provider.tier}`]
|
|
2479
|
-
: []),
|
|
4310
|
+
...(report.provider.tier ? [`- **Bounty tier**: ${report.provider.tier}`] : []),
|
|
2480
4311
|
`- **Score**: ${report.score.total}/${report.score.max}`,
|
|
2481
4312
|
`- **Verdict**: ${report.score.verdict}`,
|
|
2482
4313
|
`- **Blockers**: ${report.summary.blockers}`,
|
|
@@ -2489,12 +4320,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
|
|
|
2489
4320
|
];
|
|
2490
4321
|
|
|
2491
4322
|
for (const check of report.checks) {
|
|
2492
|
-
const status =
|
|
2493
|
-
check.status === "pass"
|
|
2494
|
-
? "PASS"
|
|
2495
|
-
: check.status === "warn"
|
|
2496
|
-
? "WARN"
|
|
2497
|
-
: "FAIL";
|
|
4323
|
+
const status = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
|
|
2498
4324
|
lines.push(
|
|
2499
4325
|
`| ${status} | ${escapeMarkdown(check.category)} | ${escapeMarkdown(check.message)} | ${check.points}/${check.maxPoints} | ${escapeMarkdown(check.remediation ?? "")} |`,
|
|
2500
4326
|
);
|
|
@@ -2524,9 +4350,7 @@ function redact(value: string): string {
|
|
|
2524
4350
|
}
|
|
2525
4351
|
|
|
2526
4352
|
function toGlobalRegex(pattern: RegExp): RegExp {
|
|
2527
|
-
return pattern.global
|
|
2528
|
-
? pattern
|
|
2529
|
-
: new RegExp(pattern.source, `${pattern.flags}g`);
|
|
4353
|
+
return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
|
|
2530
4354
|
}
|
|
2531
4355
|
|
|
2532
4356
|
function clamp(value: number, min: number, max: number): number {
|