Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,28 @@ jobs:
- name: Build production bundle
run: npm run build

# The test:ui and test:accessibility scripts land with the milestone
# branches; until they do, npm exits "Missing script" and fails the
# required check, so these steps only run when the scripts exist.
- name: Check for browser test suites
id: browser-tests
run: |
node -e '
const s = require("./package.json").scripts;
require("fs").appendFileSync(process.env.GITHUB_OUTPUT,
`ui=${s["test:ui"] ? "yes" : "no"}\naccessibility=${s["test:accessibility"] ? "yes" : "no"}\n`);
'

- name: Install Playwright Chromium
if: steps.browser-tests.outputs.ui == 'yes' || steps.browser-tests.outputs.accessibility == 'yes'
run: npx playwright install chromium --with-deps

- name: Run browser tests
if: steps.browser-tests.outputs.ui == 'yes'
run: npm run test:ui

- name: Run accessibility modal tests
if: steps.browser-tests.outputs.accessibility == 'yes'
run: npm run test:accessibility

- name: Run deploy guard
Expand Down
53 changes: 40 additions & 13 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,34 +79,61 @@ in `.dart` untouched so a re-deploy is a no-op and an editor rename survives.

Before writing anything to the project the runner compiles the generated
classes in a throwaway Flutter package built from the project's own dependency
versions and refuses the deploy (HTTP 422) if the analyzer reports an error.
versions, and refuses the deploy (HTTP 422) if the analyzer reports an error.
The FlutterFlow DSL only checks that code is formattable, which accepts a call
to a named argument the package never declared. This needs the Flutter SDK in
the image, so the runner takes longer to build and to cold-start, and the
deploy script raises Cloud Run's request timeout to 900s.

A class that imports FlutterFlow scaffolding (`/backend/schema/structs/index.dart`)
is compiled too: the deploy gathers the transitive closure of each class's
project-relative imports from the export and ships it alongside, bounded by
file and byte caps. The class is written to `lib/custom_code/<stem>.dart` -
where FlutterFlow files it, so a relative import resolves from the right
directory - and the project's generated Dart under `lib/`, which is what the
leading slash resolves against. Analysis is scoped to the deployed class files
with `--no-fatal-warnings`, so an error inside the project's own scaffolding,
or a warning-only class, cannot refuse code that compiles.
A class is compiled when every package it imports resolves the same way the
project resolves it. Two cases are left out of the compile set, and each is
reported rather than silently dropped:

* It imports something FlutterFlow only generates once the app is built -
`/backend/schema/structs/index.dart`, `../flutter_flow/lat_lng.dart`. Those
files do not exist until after the deploy.
* The project declares a dependency from a source this manifest cannot
express - a `git:` or `path:` source, a private `hosted:` block, or a
`dependency_overrides` entry written that way - and the class resolves
packages at all. `pubspec.lock`, the only document recording which packages
that source brings in, never leaves the project, so the unreproducible
package can sit anywhere in the class's transitive closure and an override
can rewrite any name in the graph. The scratch package would resolve
different code, and a check against the wrong version reports a result that
does not describe what ships.

The second case is decided once per deploy, not per class, because a
project-level source can alter resolution for a class that never names it. A
class that imports or exports the unreproducible package is skipped for that
stated reason; every other package-resolving class is skipped because the
graph could not be matched exactly. A class that uses only `dart:` resolves
nothing through pub and is still compiled.

**Which packages the Flutter SDK supplies is read from the project's own
pubspec** - a dependency written in block form under `sdk:` is an SDK package by
definition. Nothing keeps a list of SDK package names, because a list goes stale
the moment the SDK ships one it has not heard of, and a stale list reads a
perfectly valid package as unreproducible. That is not hypothetical: a list
missing `flutter_web_plugins` refused whole deploys for projects that declared
it.

The manifest is sent as package names and version constraints, never as
pubspec.yaml text, and the runner builds the document itself - it runs
`pub get` against it on a publicly reachable route, so a caller must not be
able to name a git or path source. `dependency_overrides` are carried over,
because a scratch package resolving different versions than the project would
approve code the project rejects.
approve code the project rejects. The runner validates each name against a
package-name pattern rather than against a list of SDK packages, and writes
`sdk: flutter` itself, so that value never comes from a caller.

A client still sending the older `pubspec` format is recognized, never
executed, and its deploy proceeds with the check reported as unavailable.

**Nothing unverified is deployed.** The web app refuses the deploy on any class
it cannot compile, naming each one and why, before the runner is called.
**What could not be verified still deploys, and is reported.** A class with a
real analyzer error refuses the deploy - HTTP 422, naming the error. A class the
deploy could not compile, for either reason above, does not: it is pushed, and
the warning naming it is logged *before* the provisioning request, so it
describes the mutation before it happens rather than after.

The runner streams its progress. When the request body sets `"stream": true`
the response is NDJSON — one `{"event":"phase"|"log"|"result"}` object per
Expand Down
29 changes: 16 additions & 13 deletions cloud-run/ffai-runner/bin/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ final _packageNamePattern = RegExp(r'^[a-z_][a-z0-9_]*$');
// unrepresentable rather than merely discouraged.
final _constraintPattern = RegExp(r'^[A-Za-z0-9^~<>=*+._ -]+$');

// The packages the Flutter SDK supplies, i.e. the only valid `sdk: flutter`
// entries besides `flutter` itself.
const allowedSdkPackages = <String>{
'flutter',
'flutter_test',
'flutter_driver',
'flutter_localizations',
'integration_test',
};
// Which packages the Flutter SDK supplies is not decided here. A caller names
// the packages its project declares as `sdk: flutter`, and this runner emits
// them as `name: {sdk: flutter}` - a value it writes itself, never one a caller
// sent. There was an allowlist of SDK package names here, and it refused good
// deploys: the client classified a package correctly by its pubspec source, and
// the runner rejected it for not appearing on a list that had gone stale.
// `_packageNamePattern` is what actually keeps a key from carrying YAML.

Future<void> main() async {
final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080;
Expand Down Expand Up @@ -538,8 +536,13 @@ Map<String, String> _normalizeDependencyMap(Object? value, String field) {
return result;
}

/// Reads the SDK-supplied package names. Restricted to the packages the Flutter
/// SDK actually provides, so a caller cannot request an arbitrary `sdk:` value.
/// Reads the SDK-supplied package names a caller's project declares.
///
/// Validated as package names rather than against a list of the packages the
/// Flutter SDK happens to ship: a name the SDK does not provide fails
/// `pub get` with an honest error, whereas a list refuses correct deploys the
/// moment it falls behind the SDK. The pattern is the load-bearing check - each
/// name becomes a key in the generated pubspec.
List<String> _normalizeSdkPackages(Object? value) {
if (value == null) return const <String>[];
if (value is! List) {
Expand All @@ -549,8 +552,8 @@ List<String> _normalizeSdkPackages(Object? value) {
final result = <String>[];
for (final raw in value) {
final name = '$raw';
if (!allowedSdkPackages.contains(name)) {
throw FormatException('Unsupported SDK package: $name.');
if (!_packageNamePattern.hasMatch(name)) {
throw FormatException('Invalid SDK package name: $name.');
}
if (!result.contains(name)) result.add(name);
}
Expand Down
166 changes: 83 additions & 83 deletions dist/assets/index-BAqMCFqg.js → dist/assets/index-CodKApzz.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2420,7 +2420,7 @@
.pm-cards { padding: 0 16px; }
}
</style>
<script type="module" crossorigin src="/assets/index-BAqMCFqg.js"></script>
<script type="module" crossorigin src="/assets/index-CodKApzz.js"></script>
</head>
<body>
<div class="app-shell">
Expand Down
95 changes: 95 additions & 0 deletions scripts/scan_bundle_for_leaks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import os
import re
import sys

DIST_DIR = "dist"

# Provider hosts the client must never call directly - provider traffic is
# expected to go through the app's own proxies, so a bare endpoint in a
# client-side asset means a key is going over the wire unproxied. The hosts
# legitimately appear inside the `api/*-proxy.php` files, which are the
# proxies, so these patterns only apply to files a browser executes.
CLIENT_FILE_SUFFIXES = (".js", ".html", ".css")

PROVIDER_ENDPOINTS = {
"api.groq.com endpoint": r"api\.groq\.com",
"api.openai.com endpoint": r"api\.openai\.com",
"api.anthropic.com endpoint": r"api\.anthropic\.com",
"generativelanguage.googleapis.com endpoint": r"generativelanguage\.googleapis\.com",
}

# An identifier naming a credential (`apiKey`, `api_key`, `x-api-key`,
# `VITE_GEMINI_API_KEY`, `apiSecret`, ...) assigned a quoted string literal,
# in either `key: "value"` or `KEY="value"` form. The value must be at least
# 8 characters so empty placeholders like `apiKey: ""` do not trip the gate.
KEY_ASSIGNMENT = (
r"[A-Za-z0-9_$-]*api[_-]?(?:key|secret)[A-Za-z0-9_$-]*['\"]?"
r"\s*[:=]\s*['\"][^'\"\s]{8,}['\"]"
)

# Credential shapes for the providers this app works with. A key baked into
# the bundle at build time (e.g. via a `define` replacement) carries no
# identifier, so only its format gives it away.
PROVIDER_KEY_FORMATS = {
"Google API key": r"AIza[0-9A-Za-z_-]{35}",
"Anthropic API key": r"sk-ant-[0-9A-Za-z_-]{20,}",
"OpenAI API key": r"sk-(?:proj-|svcacct-)?[0-9A-Za-z_-]{20,}",
"Groq API key": r"gsk_[0-9A-Za-z]{20,}",
"Stripe secret key": r"(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}",
}

ENDPOINT_PATTERNS = {
label: re.compile(body) for label, body in PROVIDER_ENDPOINTS.items()
}

# Key material is checked in every bundled file, client or server-side alike:
# a credential does not belong in a shipped artifact wherever it sits.
KEY_PATTERNS = {
"API key/secret assignment": re.compile(KEY_ASSIGNMENT, re.IGNORECASE),
**{label: re.compile(body) for label, body in PROVIDER_KEY_FORMATS.items()},
}


def main() -> int:
if not os.path.isdir(DIST_DIR):
print(f"{DIST_DIR}/ directory not found; skipping scan.")
return 0

leaks = []
for root, _dirs, files in os.walk(DIST_DIR):
for name in files:
if name.endswith(".map"):
continue
path = os.path.join(root, name)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
print(f"WARN: could not read {path}: {e}", file=sys.stderr)
continue
patterns = dict(KEY_PATTERNS)
if name.endswith(CLIENT_FILE_SUFFIXES):
patterns.update(ENDPOINT_PATTERNS)
for label, pattern in patterns.items():
for match in pattern.finditer(content):
line_no = content.count("\n", 0, match.start()) + 1
leaks.append((path, line_no, label))

if leaks:
print(
"FAIL: Potential provider API key/endpoint leak detected in dist/",
file=sys.stderr,
)
# Report the location and which pattern hit, never the match itself:
# a hit can contain the credential it detected, and CI logs persist.
for path, line_no, label in sorted(set(leaks)):
print(f" {path}:{line_no} [{label}]", file=sys.stderr)
return 1

print("No API key/endpoint leaks found.")
return 0


if __name__ == "__main__":
sys.exit(main())
90 changes: 66 additions & 24 deletions src/customCodeVerification.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ import { identifierToFlutterFlowFileStem } from "./flutterFlowArtifactValidation
// only guards a malformed manifest.
const FALLBACK_SDK_CONSTRAINT = ">=3.0.0 <4.0.0";

// Packages the Flutter SDK supplies, declared as `sdk: flutter` rather than
// with a pub version. This mirrors the allowlist the deploy runner enforces,
// so anything named here can be reproduced in the scratch package.
const SDK_DEPENDENCIES = new Set([
"flutter",
"flutter_test",
"flutter_driver",
"flutter_localizations",
"integration_test",
]);
// Which packages the Flutter SDK supplies is read from the project's own
// pubspec, never from a list kept here. A block-form entry whose first own-key
// is `sdk:` is an SDK package by definition, so the classification cannot fall
// behind the SDK: when Flutter ships a new package, a project declaring it
// classifies correctly with no change to this file.
//
// The list this replaced could not do that. It omitted `flutter_web_plugins`,
// which turned every project declaring it - every project with web support and
// a plugin - into one whose dependencies could not be reproduced, and refused
// the whole deploy for a package most of its classes never imported.

// Why the verification manifest is sent as structured data rather than as
// pubspec.yaml text: the deploy runner writes the manifest to disk and runs
Expand Down Expand Up @@ -71,17 +71,29 @@ export function classifyCustomClassImports(code = "") {
function collect(declared, into, sdkPackages) {
const unrepresentable = [];
for (const [name, info] of declared) {
if (SDK_DEPENDENCIES.has(name) && !info.isScalar) {
if (info.isScalar) {
into[name] = unquoteConstraint(info.constraint);
continue;
}

// A block-form entry's own-keys say where the package comes from (see
// readSourceDirective). `sdk:` is the Flutter SDK, which the runner
// reproduces from the name alone as `name: {sdk: flutter}`. A `version:`
// with no source key beside it is simply a constraint written on its own
// line.
if (info.sourceKey === "sdk") {
sdkPackages.add(name);
continue;
}
if (!info.isScalar) {
// `git:`, `path:`, a nested `hosted:` block - none can be reproduced
// outside the project, and none can be expressed in the manifest.
unrepresentable.push(name);
if (info.sourceKey === "version") {
into[name] = unquoteConstraint(info.sourceValue);
continue;
}
into[name] = unquoteConstraint(info.constraint);

// `git:`, `path:`, a nested `hosted:` block - none can be reproduced
// outside the project, and none can be expressed in a manifest that
// carries names and constraints only.
unrepresentable.push(name);
}
return unrepresentable;
}
Expand Down Expand Up @@ -156,11 +168,18 @@ function skipReason(className, unresolvableImports, missingPackages) {
* FlutterFlow. Classes that depend on the generated app are reported in
* `skipped` so the deploy can say plainly what it did not verify.
*
* When the project declares a dependency that cannot be reproduced outside it
* (a `git:` or `path:` source), nothing is verified: the scratch package would
* resolve packages differently from the project, and a check against different
* versions is worse than no check, because it reports a result that does not
* describe the code that actually ships.
* A dependency that cannot be reproduced outside the project - a `git:`,
* `path:`, or private `hosted:` source - stops every class that resolves
* packages, not only the ones that name it. The scratch manifest omits the
* project's source, so `pub get` resolves a different graph, and the
* difference is not confined to direct importers: the unreproducible package
* can sit in the transitive closure of anything a class imports, and a
* `dependency_overrides` entry applies to whatever resolves that name
* anywhere in the graph. Without the resolved graph there is no way to prove
* a class is unaffected, so it is left out of `sources` and named in
* `skipped` rather than compiled against a resolution the project would not
* produce. A class using only `dart:` resolves nothing through pub and is
* still verified.
*
* @param {Array<{className: string, content: string}>} classes - Classes to deploy
* @param {string} projectPubspecYaml - The project's merged pubspec.yaml
Expand All @@ -183,16 +202,39 @@ export function planCustomCodeVerification(classes, projectPubspecYaml) {
for (const entry of classes) {
const { className, content } = entry;

if (unrepresentable.length > 0) {
const importedPackages = extractPackageImports(content);

// Global, not scoped to this class's imports: the scratch manifest drops
// the project's source for these packages, so the graph it resolves can
// differ from the project's anywhere - including a package this class
// only reaches transitively, or a name a `dependency_overrides` entry
// redirects for every package that depends on it. Only pubspec.lock -
// which a deploy never sees - records which packages those are, so any
// class that resolves packages at all is reported rather than compiled.
// A class using only `dart:` resolves nothing through pub and is
// unaffected, so it is still verified.
const resolvesPackages = extractImportUris(content).some((uri) =>
uri.startsWith("package:"),
);
Comment on lines +205 to +218

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Transitive overrides bypass verification

Unrepresentable Git, path, or hosted dependencies are now checked only against the class's direct imports. If one of these dependencies or overrides affects a transitive dependency of an imported package, the class is still sent for compilation even though the scratch manifest omits the project's source or override. Verification then resolves different code and can report a false pass. These overrides must remain global unless the complete resolved dependency graph proves they cannot affect the class.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/customCodeVerification.js
Line: 204-211

Comment:
**Transitive overrides bypass verification**

Unrepresentable Git, path, or hosted dependencies are now checked only against the class's direct imports. If one of these dependencies or overrides affects a transitive dependency of an imported package, the class is still sent for compilation even though the scratch manifest omits the project's source or override. Verification then resolves different code and can report a false pass. These overrides must remain global unless the complete resolved dependency graph proves they cannot affect the class.

**Knowledge Base Used:**
- [Custom code verification](https://app.greptile.com/connect-i-o/-/custom-context/knowledge-base/sgardoll/customcodeconnectforflutterflow/-/docs/custom-code-verification.md)
- [Dart dependency synchronization](https://app.greptile.com/connect-i-o/-/custom-context/knowledge-base/sgardoll/customcodeconnectforflutterflow/-/docs/dart-dependency-synchronization.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Devin

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved

if (unrepresentable.length > 0 && resolvesPackages) {
// When the class does name one of these packages itself, the reason
// says so, because that is the actionable case.
const unresolvablePackages = importedPackages.filter((name) =>
unrepresentable.includes(name),
);
skipped.push({
className,
reason: `${className} was not compiled before deploying: your project declares ${unrepresentable.join(", ")} from a source that cannot be reproduced outside the project, so package resolution could not be matched exactly.`,
reason:
unresolvablePackages.length > 0
? `${className} was not compiled before deploying: it imports or exports ${unresolvablePackages.join(", ")}, which your project declares from a source that cannot be reproduced outside it, so package resolution could not be matched exactly.`
: `${className} was not compiled before deploying: your project declares ${unrepresentable.join(", ")} from a source that cannot be reproduced outside it, and such a source can alter package resolution anywhere in the dependency graph, so resolution could not be matched exactly.`,
});
continue;
}

const { unresolvableImports } = classifyCustomClassImports(content);
const missingPackages = extractPackageImports(content).filter(
const missingPackages = importedPackages.filter(
(name) => !availablePackages.has(name),
);

Expand Down
Loading
Loading