@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +39 -0
- package/CHANGELOG.md +4 -0
- package/bin/apifuse-submit-check.ts +240 -13
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/self-test.d.ts +101 -0
- package/dist/server/self-test.js +670 -112
- package/dist/server/serve.js +2 -1
- package/package.json +1 -1
- package/src/server/index.ts +5 -0
- package/src/server/self-test.ts +852 -127
- package/src/server/serve.ts +7 -1
package/AUTHORING.md
CHANGED
|
@@ -32,6 +32,45 @@ No retry templates, next-action routing, or other agent choreography: provider p
|
|
|
32
32
|
|
|
33
33
|
Declare the union in the operation `output` schema (`z.union([CreatedSchema, NeedsInputSchema])`). Reserve hard errors for genuinely unrecoverable flows (payment-gated, unsupported input kinds) and state the concrete reason in the error `message` itself, not only in `details`. Reference implementation: `providers/catchtable` `reserve` in the platform monorepo.
|
|
34
34
|
|
|
35
|
+
### Attempt tokens: the server carries the decisions
|
|
36
|
+
|
|
37
|
+
When a mutation needs more than one user decision (or one decision plus a
|
|
38
|
+
final go/no-go), do not make the agent re-send accumulated state across
|
|
39
|
+
rounds — weak models drop or corrupt it. Split the operation into a
|
|
40
|
+
**prepare/confirm pair** driven by a server-held attempt record:
|
|
41
|
+
|
|
42
|
+
- The prepare operation is non-destructive. A start call takes only the
|
|
43
|
+
scalar intent fields; every response returns a fresh `attempt_token`
|
|
44
|
+
referencing a server-side record (`ctx.choice.issue` with
|
|
45
|
+
`storage.mode: "server"`) that stores every settled decision. Continue
|
|
46
|
+
calls take `attempt_token` plus only the NEW answers.
|
|
47
|
+
- `needs_input` rounds list only the still-pending selections; settled
|
|
48
|
+
decisions may ride along in a display-only field but are never re-sent.
|
|
49
|
+
- When nothing is pending, the prepare operation returns `status: "ready"`
|
|
50
|
+
with a human-readable summary — the consumer's user-facing confirmation.
|
|
51
|
+
- The confirm operation is the only mutation and takes exactly
|
|
52
|
+
`{attempt_token}`. It re-validates everything live before executing and
|
|
53
|
+
returns `needs_input` (fresh token) instead of proceeding when upstream
|
|
54
|
+
drift invalidates a stored decision — never substitute a different option
|
|
55
|
+
for what the user picked.
|
|
56
|
+
- Expired or foreign tokens fail factually (nothing happened; start a new
|
|
57
|
+
attempt with the scalar fields) — no answer salvage from a dead token.
|
|
58
|
+
- **The provider must enforce consumption itself.** `ctx.choice` server
|
|
59
|
+
storage keeps tokens parseable until TTL — `parse` does not invalidate
|
|
60
|
+
them, so a confirm handler that only parses can be replayed into a second
|
|
61
|
+
booking or payment. After a successful execution, record the result under
|
|
62
|
+
the token's digest in `ctx.state` and make replays idempotent: a repeated
|
|
63
|
+
confirm returns the original created payload without touching upstream,
|
|
64
|
+
and later prepare rounds on the consumed token fail factually with the
|
|
65
|
+
existing reference. Record the result only after upstream success, so an
|
|
66
|
+
interrupted confirm stays retryable.
|
|
67
|
+
|
|
68
|
+
The invariant behind all of it: complex flow state is the system's job, not
|
|
69
|
+
the model's. The model carries exactly one opaque key between calls.
|
|
70
|
+
Reference implementations: `providers/catchtable` `reserve`/`reserve-confirm`
|
|
71
|
+
(including the consume-on-success guard) and `providers/modu-parking`
|
|
72
|
+
payment state tokens in the platform monorepo.
|
|
73
|
+
|
|
35
74
|
### Description template
|
|
36
75
|
|
|
37
76
|
Every operation `description` MUST be at least 150 characters and follow this structure:
|
package/CHANGELOG.md
CHANGED
|
@@ -3546,9 +3546,47 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
3546
3546
|
];
|
|
3547
3547
|
}
|
|
3548
3548
|
|
|
3549
|
+
// Splits secret findings into still-active findings and acknowledged
|
|
3550
|
+
// `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
|
|
3551
|
+
// (same pragma placement: the finding line or the line directly above it).
|
|
3552
|
+
// Every finding source carries a line number (entropy candidates and located
|
|
3553
|
+
// SECRET_PATTERNS matches); a finding that somehow lacks one stays active
|
|
3554
|
+
// defensively.
|
|
3555
|
+
function partitionSecretScanAllowOverrides(
|
|
3556
|
+
providerRoot: string,
|
|
3557
|
+
findings: readonly SecretFinding[],
|
|
3558
|
+
): { active: SecretFinding[]; overridden: SecretFinding[] } {
|
|
3559
|
+
const fileLineCache = new Map<string, string[]>();
|
|
3560
|
+
const active: SecretFinding[] = [];
|
|
3561
|
+
const overridden: SecretFinding[] = [];
|
|
3562
|
+
|
|
3563
|
+
for (const finding of findings) {
|
|
3564
|
+
if (finding.line === undefined) {
|
|
3565
|
+
active.push(finding);
|
|
3566
|
+
continue;
|
|
3567
|
+
}
|
|
3568
|
+
const absolute = resolve(providerRoot, finding.file);
|
|
3569
|
+
let lines = fileLineCache.get(absolute);
|
|
3570
|
+
if (lines === undefined) {
|
|
3571
|
+
lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
|
|
3572
|
+
fileLineCache.set(absolute, lines);
|
|
3573
|
+
}
|
|
3574
|
+
if (hasAllowOverride(lines, finding.line, "secret-scan")) {
|
|
3575
|
+
overridden.push(finding);
|
|
3576
|
+
} else {
|
|
3577
|
+
active.push(finding);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
return { active, overridden };
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3549
3584
|
function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
|
|
3550
|
-
const
|
|
3551
|
-
|
|
3585
|
+
const { active, overridden } = partitionSecretScanAllowOverrides(
|
|
3586
|
+
providerRoot,
|
|
3587
|
+
findSecretFindings(providerRoot, provider?.id),
|
|
3588
|
+
);
|
|
3589
|
+
const blockerFindings = active.filter((finding) => finding.level !== "warn");
|
|
3552
3590
|
if (blockerFindings.length > 0) {
|
|
3553
3591
|
return {
|
|
3554
3592
|
id: "secret-scan",
|
|
@@ -3568,7 +3606,15 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
|
|
|
3568
3606
|
),
|
|
3569
3607
|
};
|
|
3570
3608
|
}
|
|
3571
|
-
if (
|
|
3609
|
+
if (active.length > 0 || overridden.length > 0) {
|
|
3610
|
+
const messageBase =
|
|
3611
|
+
active.length > 0
|
|
3612
|
+
? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
|
|
3613
|
+
: "Potential credential-like strings were found in shareable files.";
|
|
3614
|
+
const message =
|
|
3615
|
+
overridden.length > 0
|
|
3616
|
+
? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
|
|
3617
|
+
: messageBase;
|
|
3572
3618
|
return {
|
|
3573
3619
|
id: "secret-scan",
|
|
3574
3620
|
category: "security",
|
|
@@ -3576,11 +3622,10 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
|
|
|
3576
3622
|
status: "warn",
|
|
3577
3623
|
points: 8,
|
|
3578
3624
|
maxPoints: CATEGORY_MAX_POINTS.security,
|
|
3579
|
-
message
|
|
3580
|
-
"High-entropy source strings were found without secret-like identifier context; they may be false positives.",
|
|
3625
|
+
message,
|
|
3581
3626
|
remediation:
|
|
3582
|
-
'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
|
|
3583
|
-
evidence:
|
|
3627
|
+
'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public with `// @apifuse-allow secret-scan: <reason>`.',
|
|
3628
|
+
evidence: [...active, ...overridden].map(
|
|
3584
3629
|
(finding) =>
|
|
3585
3630
|
finding.evidence ??
|
|
3586
3631
|
`${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
|
|
@@ -3611,8 +3656,19 @@ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFi
|
|
|
3611
3656
|
if (!existsSync(filePath)) continue;
|
|
3612
3657
|
const content = readFileSync(filePath, "utf8");
|
|
3613
3658
|
for (const [label, pattern] of SECRET_PATTERNS) {
|
|
3614
|
-
|
|
3615
|
-
|
|
3659
|
+
// Locate every match to its line so pattern findings carry the line
|
|
3660
|
+
// information hasAllowOverride needs: `// @apifuse-allow secret-scan`
|
|
3661
|
+
// must behave uniformly across entropy findings and pattern findings.
|
|
3662
|
+
const globalPattern = new RegExp(
|
|
3663
|
+
pattern.source,
|
|
3664
|
+
pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
|
|
3665
|
+
);
|
|
3666
|
+
const seenLines = new Set<number>();
|
|
3667
|
+
for (const match of content.matchAll(globalPattern)) {
|
|
3668
|
+
const line = offsetToLine(content, match.index);
|
|
3669
|
+
if (seenLines.has(line)) continue;
|
|
3670
|
+
seenLines.add(line);
|
|
3671
|
+
findings.push({ label, file: relativePath, line });
|
|
3616
3672
|
}
|
|
3617
3673
|
}
|
|
3618
3674
|
}
|
|
@@ -3670,7 +3726,7 @@ export function extractStringLiteralCandidates(line: string): string[] {
|
|
|
3670
3726
|
continue;
|
|
3671
3727
|
}
|
|
3672
3728
|
if (char === quote) {
|
|
3673
|
-
if (cursor - contentStart >=
|
|
3729
|
+
if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
|
|
3674
3730
|
candidates.push(line.slice(contentStart, cursor));
|
|
3675
3731
|
}
|
|
3676
3732
|
index = cursor;
|
|
@@ -3695,6 +3751,25 @@ function classifyEntropyCandidate(input: {
|
|
|
3695
3751
|
if (!charset) return undefined;
|
|
3696
3752
|
const entropy = shannonEntropy(value);
|
|
3697
3753
|
const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
|
|
3754
|
+
// Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
|
|
3755
|
+
// "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
|
|
3756
|
+
// (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
|
|
3757
|
+
// blocker-flagged. They are never skipped — entropy classification always
|
|
3758
|
+
// runs — but when the secret-ish context comes solely from identifier-
|
|
3759
|
+
// constant-shaped literal text (the line with those literals stripped
|
|
3760
|
+
// carries no secret-ish identifier), the finding is capped at a
|
|
3761
|
+
// non-blocking warning instead of a blocker. Stripping constant-shaped
|
|
3762
|
+
// siblings — not just the candidate — matters for lines holding several
|
|
3763
|
+
// constants (e.g. an ERROR_CODES array), while quoted property keys and
|
|
3764
|
+
// header names ("Authorization", "apiKey") stay visible as genuine
|
|
3765
|
+
// external context. Assignments to `apiKey`/`token`/`secret`-style names
|
|
3766
|
+
// still escalate to blockers via the identifier side, and
|
|
3767
|
+
// `// @apifuse-allow secret-scan` remains the reviewed way to silence the
|
|
3768
|
+
// warning.
|
|
3769
|
+
const selfContextOnlyConstant =
|
|
3770
|
+
secretishContext &&
|
|
3771
|
+
isScreamingSnakeConstantValue(value) &&
|
|
3772
|
+
!SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
|
|
3698
3773
|
const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
|
|
3699
3774
|
if (entropy < threshold) return undefined;
|
|
3700
3775
|
|
|
@@ -3705,16 +3780,163 @@ function classifyEntropyCandidate(input: {
|
|
|
3705
3780
|
charset === "hex"
|
|
3706
3781
|
? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
|
|
3707
3782
|
: `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
|
|
3783
|
+
const contextNote = selfContextOnlyConstant
|
|
3784
|
+
? "; identifier-like constant (downgraded to warning)"
|
|
3785
|
+
: secretishContext
|
|
3786
|
+
? ""
|
|
3787
|
+
: "; may be a false positive";
|
|
3708
3788
|
return {
|
|
3709
3789
|
label,
|
|
3710
3790
|
file: input.file,
|
|
3711
3791
|
line: input.lineNumber,
|
|
3712
|
-
level: secretishContext ? "blocker" : "warn",
|
|
3792
|
+
level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
|
|
3713
3793
|
remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
|
|
3714
|
-
evidence: `${location}: ${label}; preview ${preview}${
|
|
3794
|
+
evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
|
|
3715
3795
|
};
|
|
3716
3796
|
}
|
|
3717
3797
|
|
|
3798
|
+
// Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
|
|
3799
|
+
// separated segments, each essentially pure alphabetic — letters optionally
|
|
3800
|
+
// followed by a SHORT digit suffix (at most 2, e.g. version markers like
|
|
3801
|
+
// "V2") — and at most 15% digits across the whole value. Dictionary-style
|
|
3802
|
+
// constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
|
|
3803
|
+
// "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
|
|
3804
|
+
// (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
|
|
3805
|
+
// uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
|
|
3806
|
+
// ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
|
|
3807
|
+
// it only decides whether a finding whose secret-ish context comes solely
|
|
3808
|
+
// from the literal's own text is downgraded from blocker to warning, so it
|
|
3809
|
+
// deliberately stays strict: values that merely contain a secret-ish word but
|
|
3810
|
+
// are not word-like constants keep full blocker severity.
|
|
3811
|
+
function isScreamingSnakeConstantValue(value: string): boolean {
|
|
3812
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
|
|
3813
|
+
const segments = value.split("_");
|
|
3814
|
+
if (segments.length < 2) return false;
|
|
3815
|
+
if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
|
|
3816
|
+
const digitCount = value.match(/[0-9]/g)?.length ?? 0;
|
|
3817
|
+
return digitCount / value.length <= 0.15;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
type LineStringLiteral = {
|
|
3821
|
+
// Index of the opening quote.
|
|
3822
|
+
start: number;
|
|
3823
|
+
// Index just past the closing quote (line end when unterminated).
|
|
3824
|
+
end: number;
|
|
3825
|
+
content: string;
|
|
3826
|
+
closed: boolean;
|
|
3827
|
+
role: "key" | "value";
|
|
3828
|
+
// Nearest unclosed bracket enclosing the literal's start, if any.
|
|
3829
|
+
container?: { bracket: "[" | "(" | "{"; index: number };
|
|
3830
|
+
};
|
|
3831
|
+
|
|
3832
|
+
// Stable identity for the container a literal sits in ("top" when the
|
|
3833
|
+
// literal is not inside any bracket on the line).
|
|
3834
|
+
function literalContainerKey(literal: LineStringLiteral): string {
|
|
3835
|
+
return literal.container
|
|
3836
|
+
? `${literal.container.bracket}${literal.container.index}`
|
|
3837
|
+
: "top";
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
// Single-pass line tokenizer: extracts every string literal with its span and
|
|
3841
|
+
// classifies its syntactic role once. A literal is a KEY when it is preceded
|
|
3842
|
+
// (ignoring whitespace) by "{", ",", "(", or the line start AND followed
|
|
3843
|
+
// (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
|
|
3844
|
+
// other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
|
|
3845
|
+
// elements, call arguments, and assignment right-hand sides, even when a
|
|
3846
|
+
// ternary's ":" happens to follow them. Uses the same quote/escape walking as
|
|
3847
|
+
// extractStringLiteralCandidates.
|
|
3848
|
+
function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
|
|
3849
|
+
const literals: LineStringLiteral[] = [];
|
|
3850
|
+
const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
|
|
3851
|
+
let index = 0;
|
|
3852
|
+
while (index < line.length) {
|
|
3853
|
+
const char = line[index];
|
|
3854
|
+
if (char !== '"' && char !== "'" && char !== "`") {
|
|
3855
|
+
if (char === "[" || char === "(" || char === "{") {
|
|
3856
|
+
bracketStack.push({ bracket: char, index });
|
|
3857
|
+
} else if (char === "]" || char === ")" || char === "}") {
|
|
3858
|
+
bracketStack.pop();
|
|
3859
|
+
}
|
|
3860
|
+
index += 1;
|
|
3861
|
+
continue;
|
|
3862
|
+
}
|
|
3863
|
+
const quote = char;
|
|
3864
|
+
const start = index;
|
|
3865
|
+
const contentStart = index + 1;
|
|
3866
|
+
let cursor = contentStart;
|
|
3867
|
+
let closed = false;
|
|
3868
|
+
while (cursor < line.length) {
|
|
3869
|
+
const inner = line[cursor];
|
|
3870
|
+
if (inner === "\\") {
|
|
3871
|
+
cursor += 2;
|
|
3872
|
+
continue;
|
|
3873
|
+
}
|
|
3874
|
+
if (inner === quote) {
|
|
3875
|
+
closed = true;
|
|
3876
|
+
break;
|
|
3877
|
+
}
|
|
3878
|
+
cursor += 1;
|
|
3879
|
+
}
|
|
3880
|
+
const contentEnd = Math.min(cursor, line.length);
|
|
3881
|
+
const end = closed ? cursor + 1 : line.length;
|
|
3882
|
+
const before = line.slice(0, start).trimEnd();
|
|
3883
|
+
const keyPreceded = before === "" || /[{,(]$/.test(before);
|
|
3884
|
+
const keyFollowed = closed && /^\s*:/.test(line.slice(end));
|
|
3885
|
+
literals.push({
|
|
3886
|
+
start,
|
|
3887
|
+
end,
|
|
3888
|
+
content: line.slice(contentStart, contentEnd),
|
|
3889
|
+
closed,
|
|
3890
|
+
role: keyPreceded && keyFollowed ? "key" : "value",
|
|
3891
|
+
container: bracketStack[bracketStack.length - 1],
|
|
3892
|
+
});
|
|
3893
|
+
index = end;
|
|
3894
|
+
}
|
|
3895
|
+
return literals;
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
// Builds the context text used to decide whether a candidate's secret-ish
|
|
3899
|
+
// context is genuine. The candidate's own literal is ALWAYS stripped
|
|
3900
|
+
// (self-context rule). SIBLING identifier-constant-shaped candidate literals
|
|
3901
|
+
// (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
|
|
3902
|
+
// stripped only when they share the candidate's non-call container — the same
|
|
3903
|
+
// `[...]` array, the same `{...}` object value list, or the bracket-free top
|
|
3904
|
+
// level (ternary arms) — so a value list of error codes cannot poison its own
|
|
3905
|
+
// members' context. Call-argument siblings (inside `(...)`) always keep their
|
|
3906
|
+
// context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
|
|
3907
|
+
// argument genuinely describes the second, so stripping it would erase real
|
|
3908
|
+
// auth/token context. KEY-role literals are never stripped.
|
|
3909
|
+
function stripIdentifierConstantLiterals(line: string, candidate: string): string {
|
|
3910
|
+
const literals = tokenizeLineStringLiterals(line);
|
|
3911
|
+
const candidateContainers = new Set<string>();
|
|
3912
|
+
for (const literal of literals) {
|
|
3913
|
+
if (literal.content === candidate) {
|
|
3914
|
+
candidateContainers.add(literalContainerKey(literal));
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
let result = "";
|
|
3918
|
+
let previousEnd = 0;
|
|
3919
|
+
for (const literal of literals) {
|
|
3920
|
+
result += line.slice(previousEnd, literal.start);
|
|
3921
|
+
const isSelf = literal.content === candidate;
|
|
3922
|
+
const isSameNonCallContainerSibling =
|
|
3923
|
+
literal.role === "value" &&
|
|
3924
|
+
literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
|
|
3925
|
+
isScreamingSnakeConstantValue(literal.content) &&
|
|
3926
|
+
literal.container?.bracket !== "(" &&
|
|
3927
|
+
candidateContainers.has(literalContainerKey(literal));
|
|
3928
|
+
if (isSelf || isSameNonCallContainerSibling) {
|
|
3929
|
+
const quote = line[literal.start] ?? "";
|
|
3930
|
+
result += quote + (literal.closed ? quote : "");
|
|
3931
|
+
} else {
|
|
3932
|
+
result += line.slice(literal.start, literal.end);
|
|
3933
|
+
}
|
|
3934
|
+
previousEnd = literal.end;
|
|
3935
|
+
}
|
|
3936
|
+
result += line.slice(previousEnd);
|
|
3937
|
+
return result;
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3718
3940
|
function shouldConsiderEntropyValue(value: string): boolean {
|
|
3719
3941
|
const lower = value.toLowerCase();
|
|
3720
3942
|
if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
|
|
@@ -3730,7 +3952,7 @@ function shouldConsiderEntropyValue(value: string): boolean {
|
|
|
3730
3952
|
if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
|
|
3731
3953
|
return false;
|
|
3732
3954
|
}
|
|
3733
|
-
return value.length >=
|
|
3955
|
+
return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
|
|
3734
3956
|
}
|
|
3735
3957
|
|
|
3736
3958
|
function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
|
|
@@ -3764,6 +3986,11 @@ function guessSecretName(line: string): string {
|
|
|
3764
3986
|
|
|
3765
3987
|
const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
|
|
3766
3988
|
|
|
3989
|
+
// Minimum length for a string literal to be considered an entropy candidate.
|
|
3990
|
+
// Shared by candidate extraction, entropy screening, and the context strip so
|
|
3991
|
+
// the three stay coherent.
|
|
3992
|
+
const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
|
|
3993
|
+
|
|
3767
3994
|
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
3768
3995
|
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
3769
3996
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createServerApp, type ServeOptions, serve } from "./serve.js";
|
|
2
|
-
export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
|
|
2
|
+
export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestAuthFlowInvoke, type SelfTestAuthFlowRoute, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
|
|
3
3
|
export { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens.js";
|
|
4
4
|
export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
|
|
5
5
|
export { DEFAULT_SELF_TEST_PORT, deriveSelfTestToken, PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV, PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV, PROVIDER_RUNTIME_SELF_TEST_PORT_ENV, resolveSelfTestMasterSecrets, type SelfTestMasterSecrets, verifySelfTestAuthorization, } from "./self-test-token.js";
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createServerApp, serve } from "./serve.js";
|
|
2
|
-
export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, SelfTestRequestSchema, } from "./self-test.js";
|
|
2
|
+
export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, SelfTestRequestSchema, } from "./self-test.js";
|
|
3
3
|
export { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens.js";
|
|
4
4
|
export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
|
|
5
5
|
export { DEFAULT_SELF_TEST_PORT, deriveSelfTestToken, PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV, PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV, PROVIDER_RUNTIME_SELF_TEST_PORT_ENV, resolveSelfTestMasterSecrets, verifySelfTestAuthorization, } from "./self-test-token.js";
|
|
@@ -61,11 +61,53 @@ export type SelfTestOperationInvoke = (args: {
|
|
|
61
61
|
data: unknown;
|
|
62
62
|
meta?: Record<string, unknown>;
|
|
63
63
|
}>;
|
|
64
|
+
export type SelfTestAuthFlowRoute = "start" | "continue";
|
|
65
|
+
/**
|
|
66
|
+
* In-process driver for the tenant app's /auth pipeline. Self-test uses it to
|
|
67
|
+
* materialize `requiresConnection` credentials through the provider's declared
|
|
68
|
+
* auth flow — the exact path production connections take — instead of
|
|
69
|
+
* injecting raw credential inputs as connection secrets.
|
|
70
|
+
*/
|
|
71
|
+
export type SelfTestAuthFlowInvoke = (args: {
|
|
72
|
+
route: SelfTestAuthFlowRoute;
|
|
73
|
+
requestId: string;
|
|
74
|
+
flowId: string;
|
|
75
|
+
/** Stable per-credential connection id — keeps login on the probe's affinity. */
|
|
76
|
+
connectionId?: string;
|
|
77
|
+
/** The probe connection's externalRef — flows reading ctx.externalRef see the same identity. */
|
|
78
|
+
externalRef?: string;
|
|
79
|
+
input?: Record<string, unknown>;
|
|
80
|
+
context?: Record<string, unknown>;
|
|
81
|
+
}) => Promise<{
|
|
82
|
+
status: number;
|
|
83
|
+
body: unknown;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Skip reason reported when a declared auth flow does not complete in a single
|
|
87
|
+
* continue (OTP, retry loop). Cross-repo contract: the health-monitor maps
|
|
88
|
+
* this exact string to `self_test_incapable`; never vary it.
|
|
89
|
+
*/
|
|
90
|
+
export declare const SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON = "auth_flow_multi_turn";
|
|
91
|
+
/**
|
|
92
|
+
* A `retry` turn after credential submission: the flow REJECTED the
|
|
93
|
+
* configured inputs (bad password, exchange failure). Distinct from the
|
|
94
|
+
* multi-turn gap so monitoring surfaces it as a real credential outage, and
|
|
95
|
+
* memoized like multi-turn so the probe does not re-submit rejected
|
|
96
|
+
* credentials every cycle (lockout safety).
|
|
97
|
+
*/
|
|
98
|
+
export declare const SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON = "auth_flow_rejected";
|
|
64
99
|
export interface SelfTestAppOptions {
|
|
65
100
|
/** Derived-token verification secrets; without them every self-test route 404s. */
|
|
66
101
|
secrets?: SelfTestMasterSecrets;
|
|
67
102
|
/** In-process invoke bound to the tenant-facing app's /v1 pipeline. */
|
|
68
103
|
invoke: SelfTestOperationInvoke;
|
|
104
|
+
/**
|
|
105
|
+
* In-process auth-flow driver bound to the tenant-facing app's /auth
|
|
106
|
+
* pipeline. Required for providers that declare `auth.mode: "credentials"`
|
|
107
|
+
* with a flow; without it their requiresConnection cases report a visible
|
|
108
|
+
* auth_flow_unavailable error instead of probing with raw inputs.
|
|
109
|
+
*/
|
|
110
|
+
authFlow?: SelfTestAuthFlowInvoke;
|
|
69
111
|
/** Overall request budget; defaults to env / 120s. */
|
|
70
112
|
requestBudgetMs?: number;
|
|
71
113
|
/** Env override for secret collection + budget resolution (tests). */
|
|
@@ -88,6 +130,65 @@ export declare function isSelfTestReadOnlyOperation(operation: OperationDefiniti
|
|
|
88
130
|
export declare function createSelfTestInvoke(app: {
|
|
89
131
|
request: (input: string, requestInit?: RequestInit) => Response | Promise<Response>;
|
|
90
132
|
}): SelfTestOperationInvoke;
|
|
133
|
+
/** Binds the self-test auth-flow driver to a tenant app's /auth pipeline in-process. */
|
|
134
|
+
export declare function createSelfTestAuthFlowInvoke(app: {
|
|
135
|
+
request: (input: string, requestInit?: RequestInit) => Response | Promise<Response>;
|
|
136
|
+
}): SelfTestAuthFlowInvoke;
|
|
137
|
+
/**
|
|
138
|
+
* How long a memoized multi-turn flow outcome suppresses re-driving the auth
|
|
139
|
+
* flow. Generous on purpose: a multi-turn ceremony (OTP, device approval) is a
|
|
140
|
+
* provider property that changes on the timescale of releases, not probe
|
|
141
|
+
* cycles, and every re-drive is a REAL upstream login submission. The cache is
|
|
142
|
+
* in-process, so a pod restart also clears the entry.
|
|
143
|
+
*/
|
|
144
|
+
export declare const SELF_TEST_MULTI_TURN_RETRY_AFTER_MS: number;
|
|
145
|
+
/**
|
|
146
|
+
* Age bound for POSITIVE cached credentials. Expiry modes that never produce
|
|
147
|
+
* a 401/403 (a 200 login page, an assertion failure) would otherwise replay
|
|
148
|
+
* the same stale session until pod restart — one re-login per day is the
|
|
149
|
+
* upstream-safe recovery for them.
|
|
150
|
+
*/
|
|
151
|
+
export declare const SELF_TEST_CREDENTIAL_MAX_AGE_MS: number;
|
|
152
|
+
export type SelfTestCredentialSessionEntry =
|
|
153
|
+
/** Flow-materialized credential reused across probe cycles. */
|
|
154
|
+
{
|
|
155
|
+
kind: "credential";
|
|
156
|
+
credential: Record<string, string>;
|
|
157
|
+
cachedAtMs: number;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Negative entry: the flow did not complete in a single continue turn
|
|
161
|
+
* (`auth_flow_multi_turn`). Memoized so subsequent cycles report the skip
|
|
162
|
+
* WITHOUT contacting the upstream again — the first attempt already
|
|
163
|
+
* submitted real credentials (and may have triggered an OTP send).
|
|
164
|
+
*/
|
|
165
|
+
| {
|
|
166
|
+
kind: "multi_turn";
|
|
167
|
+
cachedAtMs: number;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Negative entry: the flow REJECTED the submitted credential inputs
|
|
171
|
+
* (`retry` turn after continue — bad password, exchange failure).
|
|
172
|
+
* Memoized so the probe does not re-submit rejected credentials every
|
|
173
|
+
* cycle; a new entry is attempted when the inputs rotate (new hash),
|
|
174
|
+
* the TTL lapses, or the process restarts.
|
|
175
|
+
*/
|
|
176
|
+
| {
|
|
177
|
+
kind: "rejected";
|
|
178
|
+
cachedAtMs: number;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* In-process cache of per-(providerId + stable hash of credentialInputs) auth
|
|
182
|
+
* flow outcomes, so consecutive probe cycles reuse the session — or the
|
|
183
|
+
* memoized multi-turn skip — instead of logging in every cycle (upstream
|
|
184
|
+
* account safety, DR-7). Credential entries are invalidated on a probe auth
|
|
185
|
+
* failure, at most once; multi-turn entries expire after
|
|
186
|
+
* `SELF_TEST_MULTI_TURN_RETRY_AFTER_MS` or on process restart. Flow ERRORS
|
|
187
|
+
* (transport/protocol failures, thrown start/continue) are deliberately NEVER
|
|
188
|
+
* cached: they are typically transient, and retrying a failed request next
|
|
189
|
+
* cycle is not a repeated login submission.
|
|
190
|
+
*/
|
|
191
|
+
export type SelfTestCredentialSessionCache = Map<string, SelfTestCredentialSessionEntry>;
|
|
91
192
|
export declare function resolveSelfTestPort(env?: Readonly<Record<string, string | undefined>>): number;
|
|
92
193
|
/**
|
|
93
194
|
* Builds the internal self-test Hono app. This app is served on a SEPARATE
|