Skip to content
Closed
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
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
ci:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

- name: Build production bundle
run: npm run build

- name: Run deploy guard
run: npm run test:deploy-guard

- name: Run proxy allowlist tests
run: npm run test:proxies

- name: Scan production bundle for API key/endpoint leaks
run: python3 scripts/scan_bundle_for_leaks.py
46 changes: 33 additions & 13 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,34 +79,54 @@ 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.
* It imports a package the project declares from a source this manifest cannot
express: a `git:` or `path:` source, or a nested `hosted:` block. 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 **per class**, from that class's own imports. A
`git:` dependency stops only the classes that import it; classes that do not are
still compiled. Deciding it once for the whole project meant one such entry left
an entire deploy uncompiled.

**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
156 changes: 78 additions & 78 deletions dist/assets/index-BAqMCFqg.js → dist/assets/index-D4d_RpDK.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-D4d_RpDK.js"></script>
</head>
<body>
<div class="app-shell">
Expand Down
44 changes: 44 additions & 0 deletions scripts/scan_bundle_for_leaks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
import os
import re
import sys

DIST_DIR = "dist"
PATTERNS = {
"api.groq.com endpoint": re.compile(r"api\.groq\.com"),
"apiKey assignment": re.compile(r"apiKey\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE),
}

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
for label, pattern in PATTERNS.items():
for match in pattern.finditer(content):
snippet = content[max(0, match.start()-20):match.end()+20].replace("\n", " ")
leaks.append((path, label, snippet))

if leaks:
print("FAIL: Potential provider API key/endpoint leak detected in dist/", file=sys.stderr)
for path, label, snippet in leaks:
print(f" {path} [{label}]: {snippet}", file=sys.stderr)
return 1

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

if __name__ == "__main__":
sys.exit(main())
6 changes: 6 additions & 0 deletions src/ciFail.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const { test } = require('node:test');
const assert = require('node:assert');

test('deliberate CI failure for STU-345 verification', () => {
assert.strictEqual(true, false);
});
75 changes: 51 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,28 @@ 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 first own-key says where the package comes from.
// `sdk:` is the Flutter SDK, which the runner reproduces from the name
// alone as `name: {sdk: flutter}`. A nested `version:` 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 +167,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:` or
* `path:` source - stops only a class that imports it. For that class the
* scratch package would resolve different code from the project, and a check
* against the wrong versions reports a result that does not describe what
* ships, so it is left out of `sources` and named in `skipped`. A class that
* does not import it resolves everything it uses to the same versions the
* project will, so it is verified normally.
*
* That scoping is the point. Deciding it once for the whole project meant a
* single `git:` entry - or a package the SDK supplied but a hand-kept name list
* had not heard of - left every class in the deploy uncompiled, including the
* ones that never touched it.
*
* @param {Array<{className: string, content: string}>} classes - Classes to deploy
* @param {string} projectPubspecYaml - The project's merged pubspec.yaml
Expand All @@ -183,16 +201,25 @@ export function planCustomCodeVerification(classes, projectPubspecYaml) {
for (const entry of classes) {
const { className, content } = entry;

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

// Scoped to this class's own imports. A dependency the project declares
// from a source the manifest cannot express is only a problem for a class
// that actually imports it.
const unresolvablePackages = importedPackages.filter((name) =>
unrepresentable.includes(name),
);

if (unresolvablePackages.length > 0) {
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: `${className} was not compiled before deploying: it imports ${unresolvablePackages.join(", ")}, which your project declares from a source that cannot be reproduced outside it, so package 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