diff --git a/.github/actions/gradle-dependency-snapshot/action.yml b/.github/actions/gradle-dependency-snapshot/action.yml new file mode 100644 index 0000000..8423d2e --- /dev/null +++ b/.github/actions/gradle-dependency-snapshot/action.yml @@ -0,0 +1,151 @@ +name: Gradle dependency snapshot and assessment +description: >- + Resolves the Android/Gradle dependencies of the playground app for the + checked-out commit, validates the resulting dependency-graph snapshot against + the repo and Gradle's own resolution, assesses the full inventory (Gradle + Pub) + against OSV, and uploads the evidence. Never submits anything: the snapshot is + generated with a read-only token and submitted by a separate job. + +# Run after actions/checkout and step-security/harden-runner. See +# docs/DEPENDENCY_SECURITY.md. + +inputs: + fail-on-findings: + description: >- + "true" fails the step when the complete inventory contains High/Critical + (or unrated) advisories that have no active exception. An incomplete + inventory always fails. + required: false + default: "false" + +outputs: + sha: + description: Commit SHA the inventory describes. + value: ${{ steps.rev.outputs.sha }} + evidence-artifact: + description: Name of the uploaded evidence artifact. + value: dependency-evidence-${{ steps.rev.outputs.sha }} + +runs: + using: composite + steps: + - name: Resolve commit + id: rev + shell: bash + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + # AGP 9 / Gradle 9 need JDK 17+; the playground targets Java 17. + - name: Set up Java + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: "17" + + - name: Set up Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + channel: stable + flutter-version-file: .fvmrc + cache: true + + # Writes apps/playground/android/local.properties (flutter.sdk) and + # .flutter-plugins-dependencies, which Gradle needs to include the Flutter + # plugins' Android projects. --enforce-lockfile: resolve exactly what is + # committed. + - name: Install dependencies + shell: bash + run: make deps-lockfile + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + + # `generate` only runs the dependency-graph init script; nothing is + # submitted or uploaded here. Caching is off so no cache entry can carry + # state between runs of this security-relevant job. + - name: Generate dependency snapshot + uses: gradle/actions/dependency-submission@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + env: + # Bind the snapshot to the checked-out commit, not the workflow's. + GITHUB_DEPENDENCY_GRAPH_SHA: ${{ steps.rev.outputs.sha }} + with: + build-root-directory: apps/playground/android + dependency-graph: generate + cache-disabled: true + + # Gradle's own view of what the app runtime classpath resolves to. Used to + # (a) prove the snapshot is not missing resolved libraries and (b) tell + # shipped native libraries apart from build tooling. + - name: Resolve app runtime classpaths + shell: bash + working-directory: apps/playground/android + env: + # The graph init script must not emit a second snapshot for these runs. + GITHUB_DEPENDENCY_GRAPH_ENABLED: "false" + run: | + mkdir -p "$GITHUB_WORKSPACE/dependency-evidence" + for variant in release debug; do + ./gradlew -q :app:dependencies --configuration "${variant}RuntimeClasspath" \ + > "$GITHUB_WORKSPACE/dependency-evidence/gradle-${variant}-runtime-classpath.txt" + done + + - name: Validate snapshot against Gradle resolution + shell: bash + env: + SHA: ${{ steps.rev.outputs.sha }} + run: | + dart run tool/dependency_inventory.dart validate-snapshot \ + --sha "$SHA" \ + --gradle-snapshot dependency-graph-reports \ + --runtime-classpath dependency-evidence/gradle-release-runtime-classpath.txt \ + --runtime-classpath dependency-evidence/gradle-debug-runtime-classpath.txt + + - name: Assess dependency inventory + id: assess + shell: bash + env: + SHA: ${{ steps.rev.outputs.sha }} + FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }} + run: | + set +e + dart run tool/dependency_inventory.dart assess \ + --sha "$SHA" \ + --gradle-snapshot dependency-graph-reports \ + --runtime-classpath dependency-evidence/gradle-release-runtime-classpath.txt \ + --runtime-classpath dependency-evidence/gradle-debug-runtime-classpath.txt \ + --out dependency-evidence + code=$? + set -e + if [ -f dependency-evidence/assessment.md ]; then + cat dependency-evidence/assessment.md >> "$GITHUB_STEP_SUMMARY" + fi + case "$code" in + 0) ;; + 1) + echo "::warning::High/Critical (or unrated) dependency advisories need human triage - see the job summary and docs/DEPENDENCY_SECURITY.md." + if [ "$FAIL_ON_FINDINGS" = "true" ]; then exit 1; fi + ;; + *) + echo "::error::Dependency inventory is INCOMPLETE - this is not a clean result." + exit "$code" + ;; + esac + + # Self-contained evidence tied to the commit: the snapshot, Gradle's + # runtime classpaths, the full inventory and the assessment. + - name: Collect snapshot into evidence + if: always() + shell: bash + run: | + mkdir -p dependency-evidence/gradle-snapshot + if [ -d dependency-graph-reports ]; then + cp dependency-graph-reports/*.json dependency-evidence/gradle-snapshot/ 2>/dev/null || true + fi + + - name: Upload evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dependency-evidence-${{ steps.rev.outputs.sha }} + path: dependency-evidence/ + retention-days: 90 + if-no-files-found: warn diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8644041..268dfc0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,6 +15,15 @@ updates: schedule: interval: "weekly" + # Android build: Gradle/Kotlin/Android plugin versions in settings.gradle.kts. + # Complements (does not replace) the dependency-graph submission in + # .github/workflows/dependency-submission.yml, which is what makes alerts + # cover resolved native and transitive Maven dependencies. + - package-ecosystem: "gradle" + directory: "/apps/playground/android" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/dependency-exceptions.json b/.github/dependency-exceptions.json new file mode 100644 index 0000000..b5aac17 --- /dev/null +++ b/.github/dependency-exceptions.json @@ -0,0 +1,3 @@ +{ + "exceptions": [] +} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..a515742 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,43 @@ +name: Dependency Review + +# Blocks pull requests that introduce a dependency with a known High or +# Critical vulnerability, comparing the PR's dependency-graph manifests with the +# base branch. +# +# Coverage: Pub packages (pubspec.lock). Gradle/Maven changes are NOT covered +# yet — snapshots for pull requests are not generated; see "Follow-ups" in +# docs/DEPENDENCY_SECURITY.md. Read-only; findings are never auto-dismissed. + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Dependency review + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high + license-check: false diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml new file mode 100644 index 0000000..7ff8a78 --- /dev/null +++ b/.github/workflows/dependency-submission.yml @@ -0,0 +1,105 @@ +name: Dependency Submission + +# Android/Gradle dependency coverage for GitHub's dependency graph. +# +# GitHub detects Pub packages from pubspec.lock automatically but cannot see the +# native Android dependencies (Maven artifacts, Gradle/Kotlin/Android build +# plugins), because they are only known after Gradle resolves the playground +# build. This workflow resolves them, checks the snapshot is complete, and +# submits it so Dependabot alerts cover them. See docs/DEPENDENCY_SECURITY.md. +# +# Trigger notes: +# * push to main + weekly: collect, assess, submit the default-branch graph. +# * workflow_dispatch from main: same. From any other branch: collect and +# assess only (a dry run); nothing is submitted. +# * No pull_request trigger on purpose: submitting from PRs needs a +# privileged workflow that handles untrusted snapshots. See the follow-ups +# in the doc. + +on: + workflow_dispatch: + push: + branches: + - main + schedule: + - cron: "17 5 * * 1" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +jobs: + collect: + name: Collect and assess Gradle dependencies + runs-on: ubuntu-latest + timeout-minutes: 45 + # Read-only: Gradle runs third-party plugin build scripts here. + permissions: + contents: read + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # High/Critical findings are surfaced as a warning + job summary here and + # do not fail the default-branch run; an incomplete inventory does. + - name: Collect, validate and assess + uses: ./.github/actions/gradle-dependency-snapshot + + submit: + name: Submit dependency snapshot + needs: collect + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + timeout-minutes: 10 + # The only job with a write token. It runs no repository code: it downloads + # the validated snapshot, re-checks it is bound to this commit and ref, and + # posts it to the Dependency Submission API. + permissions: + contents: write + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Download evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dependency-evidence-${{ github.sha }} + path: dependency-evidence + + - name: Submit snapshot + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + REF: ${{ github.ref }} + run: | + shopt -s nullglob + files=(dependency-evidence/gradle-snapshot/*.json) + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::No dependency snapshot in the evidence artifact; refusing to submit." + exit 1 + fi + for f in "${files[@]}"; do + snap_sha=$(jq -r '.sha' "$f") + snap_ref=$(jq -r '.ref' "$f") + count=$(jq '[.manifests[].resolved | length] | add // 0' "$f") + if [ "$snap_sha" != "$SHA" ] || [ "$snap_ref" != "$REF" ] || [ "$count" -lt 100 ]; then + echo "::error::$f is not a valid snapshot for $REF@$SHA (sha=$snap_sha ref=$snap_ref components=$count)." + exit 1 + fi + gh api --method POST "repos/$REPO/dependency-graph/snapshots" --input "$f" + echo "Submitted $f ($count components) for $SHA" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/.github/workflows/release-dependency-assessment.yml b/.github/workflows/release-dependency-assessment.yml new file mode 100644 index 0000000..98a07b6 --- /dev/null +++ b/.github/workflows/release-dependency-assessment.yml @@ -0,0 +1,54 @@ +name: Release Dependency Assessment + +# Assess the exact dependency inventory of a release candidate, independent of +# the default branch's Dependabot alert counts. Run it from the Actions tab and +# give the branch, tag or full commit SHA you intend to release. +# +# Collects the Android/Gradle inventory for that commit, adds the Pub lockfile, +# checks every component against OSV, and uploads evidence bound to the SHA +# (artifact `dependency-evidence-`). The run FAILS when the inventory is +# incomplete or when High/Critical (or unrated) advisories have no active human +# exception. It never submits anything to GitHub and never dismisses or accepts +# a finding. See docs/DEPENDENCY_SECURITY.md. +# +# The candidate must contain this tooling (.github/actions/gradle-dependency-snapshot +# and tool/dependency_inventory.dart); it is checked out and run as-is. + +on: + workflow_dispatch: + inputs: + ref: + description: Release candidate branch, tag or full commit SHA + required: true + type: string + +concurrency: + group: ${{ github.workflow }}-${{ inputs.ref }} + cancel-in-progress: false + +permissions: {} + +jobs: + assess: + name: Assess release candidate + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Checkout release candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Collect, validate and assess + uses: ./.github/actions/gradle-dependency-snapshot + with: + fail-on-findings: "true" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 911c325..947b07d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,9 @@ jobs: DEPS_TARGET: ${{ matrix.deps-target }} run: make "$DEPS_TARGET" + - name: Run tooling tests + run: make test-tools + - name: Run SDK package tests run: make test-sdk-machine diff --git a/.gitignore b/.gitignore index 5b8a52e..98b0d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ coverage/ # Test artifacts (machine reports from `make test-sdk-machine`) test-results/ +# Dependency-assessment output (tool/dependency_inventory.dart, CI evidence) +dependency-evidence/ +dependency-graph-reports/ + # Android **/android/.gradle/ **/android/local.properties diff --git a/Makefile b/Makefile index 6ba43c4..72f9c2a 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ endif MELOS := $(DART) run melos .PHONY: help deps deps-lockfile get doctor analyze analyze-ci lint format format-docs format-docs-check format-check check -.PHONY: test test-sdk-machine test-playground coverage test-coverage test-sdk-coverage +.PHONY: test test-sdk-machine test-playground test-tools coverage test-coverage test-sdk-coverage .PHONY: build build-all build-android build-ios build-ios-no-codesign run run-ios run-android devices emulators .PHONY: pana-install pana pub-publish-dry-run pub-publish-force .PHONY: ios android @@ -41,6 +41,7 @@ help: @printf " test Run tests in packages that have test/\n" @printf " test-sdk-machine Run SDK tests and write machine JSON output\n" @printf " test-playground Run playground tests\n" + @printf " test-tools Run tests for the tool/ scripts (dependency inventory)\n" @printf " coverage Run tests with coverage\n" @printf " test-sdk-coverage Run SDK tests with coverage\n" @printf " check Run format-check, analyze, and test\n\n" @@ -102,6 +103,9 @@ test-sdk-machine: test-playground: cd $(PLAYGROUND_DIR) && $(FLUTTER) test +test-tools: + $(DART) run tool/dependency_inventory_test.dart + coverage test-coverage: $(MELOS) exec --concurrency 1 --dir-exists=test -- "$(FLUTTER) test --coverage" diff --git a/docs/DEPENDENCY_SECURITY.md b/docs/DEPENDENCY_SECURITY.md new file mode 100644 index 0000000..35783ce --- /dev/null +++ b/docs/DEPENDENCY_SECURITY.md @@ -0,0 +1,272 @@ +# Dependency security + +How this repository inventories, scans and triages the dependencies used to +build, test and release the Formbricks Flutter SDK, what is covered, and what is +not. + +## At a glance + +| Question | Answer | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Does the published `formbricks` package ship native code? | No. It is a Dart-only package (`flutter pub publish --dry-run` lists `lib/`, `test/`, docs and metadata; no `android/` or `ios/`). Native code arrives through the federated plugins it depends on, which the **consumer's** app resolves. | +| Where do Pub vulnerabilities come from? | GitHub's dependency graph parses the root `pubspec.lock` automatically; Dependabot alerts cover it. All 86 hosted packages were verified present. | +| Where do Android/Maven vulnerabilities come from? | Not detected automatically. The `Dependency Submission` workflow resolves the playground's Gradle build, validates the result and submits it to the dependency graph. | +| How do I check a release candidate? | Run the `Release Dependency Assessment` workflow for the exact ref/SHA. It is independent of default-branch alert counts. See [Release candidate assessment](#release-candidate-assessment). | +| Is anything dismissed or accepted automatically? | Never. Exceptions are human-authored entries in `.github/dependency-exceptions.json`; alert dismissal is a manual action in GitHub. | + +## Inventory + +Verified against `main` at `7345fa5620bc9455a743e73131f926b69584b1e9`. + +### SDK runtime (what consumers get) + +`packages/formbricks/pubspec.yaml` declares: `http`, `shared_preferences`, +`clock`, `webview_flutter`, `webview_flutter_android`, `url_launcher`, +`connectivity_plus` (plus dev-only `flutter_test`, `flutter_lints`, `mocktail`, +`fake_async`). All resolve through the single workspace lockfile `pubspec.lock` +(86 hosted packages, including transitive ones and workspace tooling such as +Melos). + +Consumers' Android builds pull native libraries through four plugins whose +`android/` projects Gradle includes: `connectivity_plus`, +`shared_preferences_android`, `url_launcher_android`, `webview_flutter_android` +(for example `androidx.core`, `androidx.datastore`, `androidx.browser`, +`androidx.webkit`). The playground's resolution of these is the best available +proxy for what a consumer app ships; the consumer's own Android Gradle Plugin, +Kotlin version and other plugins are theirs. + +### Native + +| Platform | Source of truth | Third-party native dependencies | Coverage | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| Android | `apps/playground/android` (Gradle 9.1.0, AGP 9.0.1, Kotlin 2.3.20) plus the four plugin projects above | 731 Maven artifacts resolved in total: 115 on the app runtime classpath (debug + release), 616 build tooling (AGP and plugin build-script classpaths) | `Dependency Submission` workflow + release assessment (this document) | +| iOS | `apps/playground/ios`; Swift Package Manager (generated `FlutterGeneratedPluginSwiftPackage`), no `Podfile`, no committed `Package.resolved` | None found. All four iOS plugins (`connectivity_plus`, `shared_preferences_foundation`, `url_launcher_ios`, `webview_flutter_wkwebview`) ship a `Package.swift` whose only dependency is Flutter's own framework package (`FlutterFramework`) or none. | Nothing to submit. Limitation: the Flutter engine, Xcode and Apple frameworks are not in any graph. | + +The iOS CI job caches `apps/playground/ios/Pods` and keys it on +`ios/Podfile.lock`, neither of which exists with Swift Package Manager; that +cache step is vestigial. + +### Build, test and publish + +| Item | Where | Covered by | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Pub dev tooling (Melos, `flutter_lints`, `mocktail`, …) | `pubspec.lock` | GitHub dependency graph (Pub) | +| GitHub Actions | `.github/workflows`, `.github/actions` (all pinned to commit SHAs) | Dependabot `github-actions` | +| Gradle build plugins and Gradle wrapper version | `apps/playground/android/settings.gradle.kts`, `gradle-wrapper.properties` | Dependabot `gradle`; plugin artifacts are in the Gradle snapshot; wrapper JAR checked by wrapper validation | +| Flutter SDK | `.fvmrc` (3.47.7), installed by `subosito/flutter-action` | **Not scanned.** Version-pinned; bumped by hand. | +| `pana` | `make pana-install` (`dart pub global activate pana`, unpinned) in the Pana workflow | **Not scanned.** CI-only; does not touch the published artifact. | +| `prettier@3.6.2` | `make format-docs-check` via `npx -y`; its transitive npm packages resolve at run time | **Not scanned.** CI-only. | +| SonarQube scan action, runner images, JDK, Android SDK, Xcode | Workflows / GitHub-hosted runners | Action is Dependabot-tracked; runner image contents are GitHub's. | + +### Playground and tests + +`apps/playground` is `publish_to: none`; it is not part of the pub.dev release. +The release workflow (`.github/workflows/release.yml`) formats, analyzes, runs +SDK and playground tests, does a publish dry run and publishes the Dart package. +The Android/iOS playground builds (`build.yml`) are separate CI jobs and +**do not** gate the release. Their dependency graph still matters for two +reasons: it is the only place the SDK's transitive native libraries are +resolved in this repo, and build-tool vulnerabilities can compromise CI. + +## How Gradle coverage works + +`Dependency Submission` (`.github/workflows/dependency-submission.yml`) runs on +pushes to `main`, weekly (to refresh resolution) and on demand: + +1. **`collect` job (read-only token).** Reuses the composite action + `.github/actions/gradle-dependency-snapshot`: + - sets up JDK 17 and the pinned Flutter SDK; `make deps-lockfile` writes + `local.properties` and `.flutter-plugins-dependencies` so Gradle can include + the Flutter plugin projects; + - validates the Gradle wrapper, then runs + `gradle/actions/dependency-submission` in `generate` mode (Gradle resolves + everything; the GitHub Dependency Graph Gradle Plugin records it; nothing is + submitted or cached); + - resolves `:app:dependencies` for `releaseRuntimeClasspath` and + `debugRuntimeClasspath` as an independent view of what Gradle resolved; + - validates the snapshot (below) and runs the assessment; + - uploads `dependency-evidence-` (90-day retention). +2. **`submit` job (`contents: write`, runs no repository code).** Downloads that + evidence, re-checks the snapshot is bound to this exact SHA and ref and is not + trivially small, and posts it to the Dependency Submission API. It only runs + on the default branch; dispatching from another branch is a dry run. + +Third-party Gradle build scripts execute in `collect`, which therefore never +holds a write token. + +### What "complete" means + +`tool/dependency_inventory.dart` refuses to call an inventory complete unless: + +- the snapshot exists, parses, and its `sha` equals the commit being assessed + (stale or wrong-commit snapshots are rejected); +- it contains at least 100 Maven components and the coordinates that any real + playground build resolves (Android Gradle Plugin, Kotlin Gradle Plugin, Flutter + engine embedding, `androidx.webkit`); +- every library declared literally in each Android plugin's `build.gradle` (the + plugin list comes from `.flutter-plugins-dependencies`) is in the graph, which + proves the plugin projects, not only the app module, were resolved; +- every dependency Gradle itself reports on the app's debug and release runtime + classpaths is present in the snapshot at the same version; +- `pubspec.lock` yields hosted packages and no git-sourced packages; +- the exception register (if any entries) is valid; +- every component was successfully looked up in OSV. + +Any failure makes the result **INCOMPLETE**. An incomplete result is never +reported as clean: the workflow fails, `assessment.md` says "NOT a clean result", +and `assessment.json` has `"cleanResult": false`. + +Verdicts and exit codes: + +| Verdict | Exit | Meaning | +| ------------ | ---- | ---------------------------------------------------------------------------------------------- | +| `PASS` | 0 | Complete inventory; no High/Critical/unrated advisory without an active exception. | +| `FINDINGS` | 1 | Complete inventory; at least one High/Critical/unrated advisory needs a human decision. | +| `INCOMPLETE` | 2 | Collection, cross-check or advisory lookup failed. **Not clean.** Fix the pipeline and re-run. | + +Severity comes from the advisory's `database_specific.severity` (GitHub +Security Advisories) or, failing that, a CVSS v3 base score. An advisory with +neither is **unrated** and treated as blocking until a human rates or excepts it. +Medium and Low advisories are listed but do not block. + +### Evidence + +Every run uploads `dependency-evidence-`: + +| File | Contents | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `assessment.json` / `assessment.md` | Commit SHA, timestamp, verdict, problems, counts, every advisory with scope, input hashes | +| `inventory.json` | Every component (ecosystem, name, version, scope) that was checked | +| `gradle-snapshot/*.json` | The exact dependency-graph snapshot (carries the commit SHA, ref, scan time) | +| `gradle-{release,debug}-runtime-classpath.txt` | Gradle's own resolution used for the cross-check | + +Scopes: `app-runtime` (on the playground's runtime classpath), `build-tooling` +(any other Gradle-resolved artifact: AGP internals, plugin classpaths) and `pub`. +Pub packages are not split into runtime/dev; check `pubspec.lock` +(`dependency: direct main | direct dev | transitive`). + +## Release candidate assessment + +To assess the exact commit you intend to release: + +1. Actions → **Release Dependency Assessment** → _Run workflow_, with the + release candidate branch, tag or full commit SHA. +2. Read the job summary and `dependency-evidence-`. The run fails for + `FINDINGS` and for `INCOMPLETE`. +3. Record the outcome (green run URL, or the decisions below) in the release + issue/PR. + +The candidate must contain this tooling, since it is checked out and run as-is. +This assessment is independent of default-branch Dependabot alert counts; it +inspects the resolved inventory of that SHA. + +To reproduce locally from downloaded evidence (no Android SDK needed): + +```bash +unzip dependency-evidence-.zip -d /tmp/evidence +git checkout +make deps-lockfile +dart run tool/dependency_inventory.dart assess \ + --sha "$(git rev-parse HEAD)" \ + --gradle-snapshot /tmp/evidence/gradle-snapshot \ + --runtime-classpath /tmp/evidence/gradle-release-runtime-classpath.txt \ + --runtime-classpath /tmp/evidence/gradle-debug-runtime-classpath.txt \ + --out /tmp/assessment +``` + +`make test-tools` runs the tool's tests. + +## Handling findings + +### Where findings surface + +- **Dependabot alerts** (Security tab) once the Gradle snapshot is in the graph + and for Pub packages. These are per default branch. +- **`Dependency Submission` runs** (push, weekly): a High/Critical/unrated finding + emits a warning annotation and a job summary but does not fail the run; an + `INCOMPLETE` inventory fails it. +- **`Release Dependency Assessment`** for a specific commit (fails on findings). +- **`Dependency Review`** on pull requests: fails when a PR introduces a + High/Critical vulnerability in Pub packages. + +### Triage + +Each High/Critical finding needs a human decision. Nothing here is automatic. + +1. **Locate it.** Find the `scope` in `assessment.md`. For Gradle artifacts, trace + the source (from `apps/playground/android`, after `make deps-lockfile`): + + ```bash + ./gradlew :app:dependencyInsight --configuration releaseRuntimeClasspath --dependency + ./gradlew ::buildEnvironment | grep -C 5 # plugin/AGP classpath + ``` + +2. **Classify.** + - `app-runtime` or `pub`: a runtime exposure for apps built from this SDK. + Fix before release (bump the plugin/package, or add a constraint). + - `build-tooling`: not on the app runtime classpath; the exposure is to the + build/CI environment (for example Android Gradle Plugin internals). Still + assess it: is the vulnerable code reachable from our build, does the build + process untrusted input, can the AGP/Flutter version be bumped? +3. **Decide and record.** Either: + - **Fix:** bump the dependency (Dependabot PR, Flutter/AGP upgrade, or a + Gradle dependency constraint). The next run is the evidence. + - **Exception:** open a PR adding an entry to + `.github/dependency-exceptions.json`: + + ```json + { + "exceptions": [ + { + "advisory": "GHSA-xxxx-xxxx-xxxx", + "package": "io.netty:netty-codec-http2", + "reason": "Build tooling only (AGP); not on app runtime classpath; no untrusted HTTP/2 input in our build. Tracked in ENG-1234.", + "approvedBy": "@github-handle", + "expires": "2027-01-15" + } + ] + } + ``` + + All fields are required. The exception matches the advisory ID or any alias + for that package name, applies to every version of it, must expire within 180 + days, and stops being honored after `expires`. Active exceptions stay visible in + every report. A PR that adds one needs a human reviewer; the tooling never + writes this file. + + - **Dismiss the Dependabot alert** in GitHub with an accurate reason, only + as a human decision. Nothing in this repo dismisses alerts. + +An invalid register, an expired entry, or an exception with no matching finding +never silently clears anything: invalid registers make the run INCOMPLETE and +expired entries are reported as expired. + +## Limitations and follow-ups + +- **Pull requests are not covered for Gradle.** `Dependency Review` sees Pub + changes only. Generating PR snapshots needs a split design: generate with a + read-only token on `pull_request`, then submit from a privileged + `workflow_run` job. That job must verify the artifact's `sha` equals the PR head + SHA and its `ref` is `refs/pull//merge`/head. A forged snapshot with the + default branch's SHA and ref would otherwise overwrite the default-branch graph. + Not implemented here to avoid shipping that risk untested. +- **The advisory data source for the assessment is OSV** (which mirrors GitHub + Security Advisories). It can differ from Dependabot's view; treat a difference + as a prompt to investigate, not as proof either way. +- **Scope split is by classpath.** `build-tooling` means "not on the debug/release + runtime classpath of `:app`", not "harmless". +- **Not scanned:** Flutter SDK, Gradle distribution, JDK, Android SDK, Xcode, + `pana`, `prettier`'s npm tree and runner images (see + [Build, test and publish](#build-test-and-publish)). Possible follow-ups: pin + and lock `pana`, add an npm lockfile for `prettier`, and track the Flutter SDK + version against Flutter security advisories. +- **iOS** has no third-party native dependencies today. If a plugin adds a + CocoaPod or Swift package, commit `Podfile.lock` / `Package.resolved` (or add a + macOS snapshot job) so GitHub's graph can see it. +- **Gradle coverage is only as fresh as the last successful submission.** The + weekly run refreshes it; if that run fails, the graph is stale and the failed + run is the signal. Treat a failing `Dependency Submission` run as an open + security task. +- **The Dependabot `gradle` entry** keeps plugin versions in + `settings.gradle.kts` current, but Dependabot cannot see versions resolved + inside Flutter plugin projects; that is what the snapshot is for. diff --git a/tool/dependency_inventory.dart b/tool/dependency_inventory.dart new file mode 100644 index 0000000..0550569 --- /dev/null +++ b/tool/dependency_inventory.dart @@ -0,0 +1,1125 @@ +// Dependency inventory + vulnerability assessment for a specific commit. +// +// Builds one inventory of everything resolved for the checked-out commit: +// * Pub packages, from the root `pubspec.lock` (workspace lockfile), and +// * Maven/Gradle artifacts, from the dependency-graph snapshot generated for +// `apps/playground/android` (GitHub Dependency Graph Gradle Plugin), +// cross-checks it against the repo (so an empty, partial, or stale snapshot is +// caught), looks every component up in OSV (https://osv.dev), and writes +// evidence files tied to the commit SHA. +// +// This is deliberately separate from GitHub's default-branch Dependabot alert +// counts: it answers "is *this* SHA's resolved inventory complete, and does it +// contain High/Critical advisories?". +// +// It never dismisses, ignores, or accepts anything on its own. Documented human +// exceptions live in `.github/dependency-exceptions.json` and are always +// reported. See docs/DEPENDENCY_SECURITY.md. +// +// Usage: +// dart run tool/dependency_inventory.dart validate-snapshot --sha --gradle-snapshot +// dart run tool/dependency_inventory.dart assess --sha --gradle-snapshot +// +// Exit codes: 0 = complete inventory, no blocking findings +// 1 = complete inventory, blocking (High/Critical/unrated) findings +// 2 = INCOMPLETE — inventory/lookup failed; NOT a clean result +// 64 = usage error +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Minimum number of Maven components a real Android build resolves. The +/// playground resolves several hundred (Android Gradle Plugin alone is dozens), +/// so anything near zero means the collection did not actually run. +const int minGradleComponents = 100; + +/// Coordinates (`group:artifact`) that must appear in any real snapshot of the +/// playground: build plugins, the Flutter engine embedding, and the AndroidX +/// WebView library the SDK's `webview_flutter_android` dependency brings in. +const List requiredGradleCoordinates = [ + 'com.android.tools.build:gradle', + 'org.jetbrains.kotlin:kotlin-gradle-plugin', + 'io.flutter:flutter_embedding_release', + 'androidx.webkit:webkit', +]; + +const int maxExceptionDays = 180; +const String osvApi = 'https://api.osv.dev/v1'; + +enum Verdict { pass, findings, incomplete } + +class Component { + const Component( + this.ecosystem, + this.name, + this.version, + this.source, { + this.scope = 'unclassified', + }); + + /// OSV ecosystem: `Maven` or `Pub`. + final String ecosystem; + + /// `group:artifact` for Maven, the package name for Pub. + final String name; + final String version; + + /// `gradle` or `pub`. + final String source; + + /// For Maven components: `app-runtime` (on the playground's release/debug + /// runtime classpath) or `build-tooling` (everything else Gradle resolves — + /// plugin classpaths, AGP internals), when a runtime classpath is supplied; + /// otherwise `unclassified`. Pub components are always `pub`. + final String scope; + + Component withScope(String scope) => + Component(ecosystem, name, version, source, scope: scope); + + String get key => '$ecosystem|$name|$version'; + + Map toJson() => { + 'ecosystem': ecosystem, + 'name': name, + 'version': version, + 'source': source, + 'scope': scope, + }; +} + +class SnapshotInfo { + SnapshotInfo(this.file, this.sha256Hex, this.json); + final String file; + final String sha256Hex; + final Map json; +} + +/// Parses `pkg:maven//@?`. +Component? componentFromPurl(String purl) { + const prefix = 'pkg:maven/'; + if (!purl.startsWith(prefix)) return null; + var rest = purl.substring(prefix.length); + final q = rest.indexOf('?'); + if (q >= 0) rest = rest.substring(0, q); + final at = rest.lastIndexOf('@'); + final slash = rest.indexOf('/'); + if (at < 0 || slash < 0 || slash > at) return null; + final group = Uri.decodeComponent(rest.substring(0, slash)); + final artifact = Uri.decodeComponent(rest.substring(slash + 1, at)); + final version = Uri.decodeComponent(rest.substring(at + 1)); + if (group.isEmpty || artifact.isEmpty || version.isEmpty) return null; + return Component('Maven', '$group:$artifact', version, 'gradle'); +} + +/// Extracts hosted Pub packages from a `pubspec.lock`. SDK and path packages +/// are not downloaded from a registry and are skipped; git packages are +/// returned in [unsupported] so the caller can flag them. +({List components, List unsupported}) parsePubLockfile( + String content, +) { + final components = []; + final unsupported = []; + String? name; + String? source; + String? version; + + void flush() { + if (name == null) return; + if (source == 'hosted' && version != null) { + components.add(Component('Pub', name!, version!, 'pub', scope: 'pub')); + } else if (source == 'git') { + unsupported.add(name!); + } + name = null; + source = null; + version = null; + } + + var inPackages = false; + for (final line in const LineSplitter().convert(content)) { + if (line.startsWith('packages:')) { + inPackages = true; + continue; + } + if (inPackages && line.isNotEmpty && !line.startsWith(' ')) { + flush(); + inPackages = false; + } + if (!inPackages) continue; + final entry = RegExp(r'^ ([A-Za-z0-9_]+):\s*$').firstMatch(line); + if (entry != null) { + flush(); + name = entry.group(1); + continue; + } + final field = RegExp( + r'^ (source|version):\s*"?([^"\s]+)"?\s*$', + ).firstMatch(line); + if (field != null) { + if (field.group(1) == 'source') { + source = field.group(2); + } else { + version = field.group(2); + } + } + } + flush(); + return (components: components, unsupported: unsupported); +} + +/// Components resolved in a dependency-submission snapshot. +List gradleComponents(Map snapshot) { + final out = {}; + final manifests = snapshot['manifests']; + if (manifests is! Map) return const []; + for (final manifest in manifests.values) { + if (manifest is! Map) continue; + final resolved = manifest['resolved']; + if (resolved is! Map) continue; + for (final dep in resolved.values) { + if (dep is! Map) continue; + final purl = dep['package_url']; + if (purl is! String) continue; + final c = componentFromPurl(purl); + if (c != null) out[c.key] = c; + } + } + return out.values.toList(); +} + +/// Resolved coordinates (`group:artifact@version`) from the text output of +/// `./gradlew :app:dependencies --configuration `. Constraints `(c)`, +/// unresolvable `(n)` nodes and sub-projects are skipped; `a:b:1 -> 2` yields +/// the resolved version 2. Unresolved (`FAILED`) nodes are returned in +/// [failed]. +({Set resolved, List failed}) parseRuntimeClasspath( + String content, +) { + final resolved = {}; + final failed = []; + final node = RegExp(r'^[|\s]*[+\\]---\s+(.+)$'); + final coordinate = RegExp( + r'^([\w.\-]+):([\w.\-]+)(?::(\{[^}]*\}|\S+))?(?:\s+->\s+(\S+))?', + ); + for (final line in const LineSplitter().convert(content)) { + final m = node.firstMatch(line); + if (m == null) continue; + final text = m.group(1)!; + if (text.startsWith('project ') || + text.contains('(c)') || + text.contains('(n)')) { + continue; + } + final c = coordinate.firstMatch(text); + if (c == null) continue; + final name = '${c.group(1)}:${c.group(2)}'; + if (text.contains('FAILED')) { + failed.add(name); + continue; + } + final version = c.group(4) ?? c.group(3); + if (version == null || version.startsWith('{')) continue; + resolved.add('$name@$version'); + } + return (resolved: resolved, failed: failed); +} + +/// Every dependency Gradle itself reports on the app runtime classpath must be +/// present in the snapshot at the same version. This is the independent +/// cross-check that the dependency graph matches the real resolution. +List checkRuntimeClasspathCoverage({ + required Set runtime, + required Set snapshotKeys, +}) { + final missing = runtime.where((r) => !snapshotKeys.contains(r)).toList() + ..sort(); + return [ + for (final m in missing) + 'Resolved by Gradle on the app runtime classpath but absent from the ' + 'snapshot: $m', + ]; +} + +/// Structural checks that make an empty/partial/stale snapshot impossible to +/// mistake for a clean result. Returns human-readable problems (empty = OK). +List validateSnapshot( + Map snapshot, { + required String expectedSha, + String label = 'snapshot', +}) { + final problems = []; + final sha = snapshot['sha']; + if (sha is! String || sha.toLowerCase() != expectedSha.toLowerCase()) { + problems.add( + '$label is for commit ${sha ?? ''}, expected $expectedSha ' + '(stale or wrong snapshot).', + ); + } + if (snapshot['scanned'] is! String) { + problems.add('$label has no "scanned" timestamp.'); + } + final manifests = snapshot['manifests']; + if (manifests is! Map || manifests.isEmpty) { + problems.add('$label contains no manifests.'); + return problems; + } + final components = gradleComponents(snapshot); + if (components.length < minGradleComponents) { + problems.add( + '$label resolves only ${components.length} Maven components ' + '(expected at least $minGradleComponents); dependency collection ' + 'looks empty or partial.', + ); + } + final names = components.map((c) => c.name).toSet(); + for (final required in requiredGradleCoordinates) { + if (!names.contains(required)) { + problems.add('$label is missing required coordinate $required.'); + } + } + return problems; +} + +/// Native dependencies declared literally in the Android build files of the +/// Flutter plugins the playground uses (`implementation("g:a:v")` and buildscript +/// `classpath("g:a:v")`). Each must show up in the Gradle snapshot, which +/// proves plugin sub-projects — not just the app module — were resolved. +List checkPluginCoverage({ + required String pluginsFileContent, + required Set snapshotCoordinates, + required String Function(String path) readGradleFile, +}) { + final problems = []; + final Object? decoded; + try { + decoded = jsonDecode(pluginsFileContent); + } on FormatException catch (e) { + return ['Cannot parse .flutter-plugins-dependencies: ${e.message}']; + } + final android = decoded is Map && decoded['plugins'] is Map + ? (decoded['plugins'] as Map)['android'] + : null; + if (android is! List || android.isEmpty) { + return ['.flutter-plugins-dependencies lists no Android plugins.']; + } + final declared = RegExp( + r'''(?[]; + for (final f in const ['build.gradle', 'build.gradle.kts']) { + final content = readGradleFile('${path}android/$f'); + if (content.isNotEmpty) text.add(content); + } + if (text.isEmpty) { + problems.add('Android plugin $name: no android/build.gradle found.'); + continue; + } + for (final match in declared.allMatches(text.join('\n'))) { + final coordinate = match.group(1)!; + if (!snapshotCoordinates.contains(coordinate)) { + problems.add( + 'Android plugin $name declares $coordinate but it is absent from ' + 'the Gradle snapshot.', + ); + } + } + } + return problems; +} + +// --- Severity --------------------------------------------------------------- + +String ratingFromScore(double score) { + if (score >= 9.0) return 'CRITICAL'; + if (score >= 7.0) return 'HIGH'; + if (score >= 4.0) return 'MEDIUM'; + if (score > 0.0) return 'LOW'; + return 'NONE'; +} + +/// CVSS v3.x base score from a vector string, or null if unparsable. +double? cvssV3Score(String vector) { + if (!vector.startsWith('CVSS:3')) return null; + final m = {}; + for (final part in vector.split('/').skip(1)) { + final kv = part.split(':'); + if (kv.length == 2) m[kv[0]] = kv[1]; + } + const av = {'N': 0.85, 'A': 0.62, 'L': 0.55, 'P': 0.2}; + const ac = {'L': 0.77, 'H': 0.44}; + const ui = {'N': 0.85, 'R': 0.62}; + const cia = {'H': 0.56, 'L': 0.22, 'N': 0.0}; + final changed = m['S'] == 'C'; + final pr = changed + ? const {'N': 0.85, 'L': 0.68, 'H': 0.5} + : const {'N': 0.85, 'L': 0.62, 'H': 0.27}; + final a = av[m['AV']]; + final c = ac[m['AC']]; + final p = pr[m['PR']]; + final u = ui[m['UI']]; + final ci = cia[m['C']]; + final ii = cia[m['I']]; + final ai = cia[m['A']]; + if ([a, c, p, u, ci, ii, ai].contains(null) || m['S'] == null) return null; + final iss = 1 - (1 - ci!) * (1 - ii!) * (1 - ai!); + final impact = changed + ? 7.52 * (iss - 0.029) - 3.25 * _pow(iss - 0.02, 15) + : 6.42 * iss; + if (impact <= 0) return 0; + final exploitability = 8.22 * a! * c! * p! * u!; + final raw = changed + ? 1.08 * (impact + exploitability) + : impact + exploitability; + return _roundUp(raw > 10 ? 10 : raw); +} + +double _pow(double base, int exp) { + var r = 1.0; + for (var i = 0; i < exp; i++) { + r *= base; + } + return r; +} + +double _roundUp(double v) { + final i = (v * 100000).round(); + if (i % 10000 == 0) return i / 100000.0; + return ((i ~/ 10000) + 1) / 10.0; +} + +/// `CRITICAL|HIGH|MEDIUM|LOW|UNRATED` for an OSV vulnerability record. +String severityOf(Map vuln) { + final db = vuln['database_specific']; + if (db is Map && db['severity'] is String) { + final s = (db['severity'] as String).toUpperCase(); + if (s == 'MODERATE') return 'MEDIUM'; + if (const ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].contains(s)) return s; + } + final severities = vuln['severity']; + if (severities is List) { + for (final s in severities) { + if (s is Map && s['score'] is String) { + final score = cvssV3Score(s['score'] as String); + if (score != null) return ratingFromScore(score); + } + } + } + return 'UNRATED'; +} + +bool isBlocking(String severity) => + severity == 'CRITICAL' || severity == 'HIGH' || severity == 'UNRATED'; + +// --- Exceptions ------------------------------------------------------------- + +class ExceptionEntry { + ExceptionEntry( + this.advisory, + this.package, + this.reason, + this.approvedBy, + this.expires, + ); + final String advisory; + final String package; + final String reason; + final String approvedBy; + final DateTime expires; + + Map toJson() => { + 'advisory': advisory, + 'package': package, + 'reason': reason, + 'approvedBy': approvedBy, + 'expires': expires.toIso8601String().substring(0, 10), + }; +} + +/// Parses the human exception register. Problems are returned, not thrown, so +/// an invalid register makes the assessment INCOMPLETE rather than silently +/// ignoring entries. +({ + List active, + List expired, + List problems, +}) +parseExceptions(String? content, DateTime now) { + final active = []; + final expired = []; + final problems = []; + if (content == null || content.trim().isEmpty) { + return (active: active, expired: expired, problems: problems); + } + final Object? decoded; + try { + decoded = jsonDecode(content); + } on FormatException catch (e) { + problems.add('Exception register is not valid JSON: ${e.message}'); + return (active: active, expired: expired, problems: problems); + } + final list = decoded is Map ? decoded['exceptions'] : null; + if (list is! List) { + problems.add('Exception register must be {"exceptions": [...]}.'); + return (active: active, expired: expired, problems: problems); + } + final today = DateTime.utc(now.year, now.month, now.day); + final horizon = today.add(const Duration(days: maxExceptionDays)); + for (var i = 0; i < list.length; i++) { + final e = list[i]; + String field(String k) => e is Map && e[k] is String ? e[k] as String : ''; + final missing = [ + for (final k in const [ + 'advisory', + 'package', + 'reason', + 'approvedBy', + 'expires', + ]) + if (field(k).trim().isEmpty) k, + ]; + if (missing.isNotEmpty) { + problems.add('Exception #$i is missing: ${missing.join(', ')}.'); + continue; + } + final expires = DateTime.tryParse(field('expires')); + if (expires == null) { + problems.add('Exception #$i has an invalid "expires" date.'); + continue; + } + final entry = ExceptionEntry( + field('advisory'), + field('package'), + field('reason'), + field('approvedBy'), + DateTime.utc(expires.year, expires.month, expires.day), + ); + if (entry.expires.isBefore(today)) { + expired.add(entry); + } else if (entry.expires.isAfter(horizon)) { + problems.add( + 'Exception #$i (${entry.advisory}) expires more than ' + '$maxExceptionDays days out.', + ); + } else { + active.add(entry); + } + } + return (active: active, expired: expired, problems: problems); +} + +// --- OSV -------------------------------------------------------------------- + +class Finding { + Finding(this.id, this.aliases, this.severity, this.summary); + final String id; + final List aliases; + final String severity; + final String summary; + final List components = []; + ExceptionEntry? exception; + + bool get blocking => isBlocking(severity) && exception == null; + + /// `app-runtime` if any affected component is on the app runtime classpath, + /// else `build-tooling` / `pub` / `unclassified`. + String get scope { + final scopes = components.map((c) => c.scope).toSet(); + for (final s in const ['app-runtime', 'pub', 'build-tooling']) { + if (scopes.contains(s)) return s; + } + return 'unclassified'; + } + + Map toJson() => { + 'id': id, + 'aliases': aliases, + 'severity': severity, + 'scope': scope, + 'summary': summary, + 'blocking': blocking, + 'components': [for (final c in components) '${c.name}@${c.version}'], + if (exception != null) 'exception': exception!.toJson(), + }; +} + +abstract class OsvClient { + /// For each component (same order), the OSV ids affecting it. + Future>> queryBatch(List components); + Future> vulnerability(String id); +} + +class HttpOsvClient implements OsvClient { + HttpOsvClient({HttpClient? client}) + : _client = (client ?? HttpClient()) + ..connectionTimeout = const Duration(seconds: 30); + final HttpClient _client; + + Future> _request( + String method, + String path, [ + Object? body, + ]) async { + Object? lastError; + for (var attempt = 0; attempt < 4; attempt++) { + if (attempt > 0) { + await Future.delayed(Duration(seconds: 2 << attempt)); + } + try { + final req = await _client.openUrl(method, Uri.parse('$osvApi$path')); + if (body != null) { + req.headers.contentType = ContentType.json; + req.write(jsonEncode(body)); + } + final res = await req.close().timeout(const Duration(seconds: 60)); + final text = await utf8 + .decodeStream(res) + .timeout(const Duration(seconds: 60)); + if (res.statusCode == 200) { + return jsonDecode(text) as Map; + } + lastError = 'HTTP ${res.statusCode}'; + if (res.statusCode != 429 && res.statusCode < 500) break; + } on Object catch (e) { + lastError = e; + } + } + throw StateError('OSV request $method $path failed: $lastError'); + } + + @override + Future>> queryBatch(List components) async { + final results = List>.generate(components.length, (_) => []); + const chunk = 500; + for (var start = 0; start < components.length; start += chunk) { + final slice = components.sublist( + start, + start + chunk > components.length ? components.length : start + chunk, + ); + var pending = { + for (var i = 0; i < slice.length; i++) i: null, + }; + while (pending.isNotEmpty) { + final indexes = pending.keys.toList(); + final response = await _request('POST', '/querybatch', { + 'queries': [ + for (final i in indexes) + { + 'package': { + 'name': slice[i].name, + 'ecosystem': slice[i].ecosystem, + }, + 'version': slice[i].version, + if (pending[i] != null) 'page_token': pending[i], + }, + ], + }); + final answers = response['results']; + if (answers is! List || answers.length != indexes.length) { + throw StateError('OSV querybatch returned a malformed response.'); + } + final next = {}; + for (var n = 0; n < indexes.length; n++) { + final answer = answers[n] as Map; + final i = indexes[n]; + for (final v in (answer['vulns'] as List?) ?? const []) { + results[start + i].add((v as Map)['id'] as String); + } + final token = answer['next_page_token']; + if (token is String && token.isNotEmpty) next[i] = token; + } + pending = next; + } + } + return results; + } + + @override + Future> vulnerability(String id) => + _request('GET', '/vulns/${Uri.encodeComponent(id)}'); +} + +class Assessment { + Assessment({ + required this.commit, + required this.generatedAt, + required this.problems, + required this.components, + required this.findings, + required this.expired, + required this.snapshots, + required this.lockfileSha256, + required this.osvChecked, + }); + + final String commit; + final DateTime generatedAt; + final List problems; + final List components; + final List findings; + final List expired; + final List> snapshots; + final String? lockfileSha256; + final bool osvChecked; + + Verdict get verdict { + if (problems.isNotEmpty || !osvChecked) return Verdict.incomplete; + return findings.any((f) => f.blocking) ? Verdict.findings : Verdict.pass; + } + + Map toJson() => { + 'commit': commit, + 'generatedAt': generatedAt.toUtc().toIso8601String(), + 'verdict': verdict.name.toUpperCase(), + 'cleanResult': verdict == Verdict.pass, + 'problems': problems, + 'osvChecked': osvChecked, + 'counts': { + 'components': components.length, + 'gradle': components.where((c) => c.source == 'gradle').length, + 'pub': components.where((c) => c.source == 'pub').length, + 'findings': findings.length, + 'blocking': findings.where((f) => f.blocking).length, + }, + 'inputs': { + 'gradleSnapshots': snapshots, + 'pubspecLockSha256': lockfileSha256, + }, + 'findings': [for (final f in findings) f.toJson()], + 'expiredExceptions': [for (final e in expired) e.toJson()], + }; + + String toMarkdown() { + final b = StringBuffer() + ..writeln('# Dependency assessment') + ..writeln() + ..writeln('- Commit: `$commit`') + ..writeln('- Generated: ${generatedAt.toUtc().toIso8601String()}'); + switch (verdict) { + case Verdict.pass: + b.writeln( + '- **Result: PASS** — complete inventory, no unexcepted ' + 'High/Critical/unrated advisories.', + ); + case Verdict.findings: + b.writeln( + '- **Result: FINDINGS** — human triage required before release.', + ); + case Verdict.incomplete: + b.writeln( + '- **Result: INCOMPLETE — this is NOT a clean result.** ' + 'The inventory or advisory lookup did not complete.', + ); + } + b + ..writeln( + '- Components: ${components.length} ' + '(${components.where((c) => c.source == 'gradle').length} Maven/Gradle, ' + '${components.where((c) => c.source == 'pub').length} Pub)', + ) + ..writeln(); + if (problems.isNotEmpty) { + b.writeln('## Problems'); + for (final p in problems) { + b.writeln('- $p'); + } + b.writeln(); + } + if (expired.isNotEmpty) { + b.writeln('## Expired exceptions (not honored)'); + for (final e in expired) { + b.writeln( + '- ${e.advisory} / ${e.package} expired ${e.toJson()['expires']}', + ); + } + b.writeln(); + } + if (findings.isNotEmpty) { + b + ..writeln('## Advisories') + ..writeln() + ..writeln('| Severity | Advisory | Scope | Components | Status |') + ..writeln('| --- | --- | --- | --- | --- |'); + final ordered = [...findings] + ..sort((a, b) => _rank(a.severity).compareTo(_rank(b.severity))); + for (final f in ordered) { + final status = f.exception != null + ? 'excepted by ${f.exception!.approvedBy} until ' + '${f.exception!.toJson()['expires']}' + : f.blocking + ? '**blocking**' + : 'non-blocking'; + final comps = f.components + .map((c) => '`${c.name}@${c.version}`') + .join(', '); + b.writeln( + '| ${f.severity} | ${f.id} | ${f.scope} | $comps | $status |', + ); + } + } + return b.toString(); + } +} + +int _rank(String s) => switch (s) { + 'CRITICAL' => 0, + 'HIGH' => 1, + 'UNRATED' => 2, + 'MEDIUM' => 3, + _ => 4, +}; + +/// Runs the full assessment. All I/O is injected so it can be unit tested. +Future assess({ + required String commit, + required List snapshots, + required String pubLockfile, + String? pubLockfileSha256, + required String? pluginsFile, + required List runtimeClasspaths, + required String Function(String path) readGradleFile, + required String? exceptionsContent, + required OsvClient? osv, + required DateTime now, +}) async { + final problems = []; + + if (snapshots.isEmpty) { + problems.add('No Gradle dependency snapshot was provided.'); + } + final components = {}; + for (final s in snapshots) { + problems.addAll( + validateSnapshot(s.json, expectedSha: commit, label: s.file), + ); + for (final c in gradleComponents(s.json)) { + components[c.key] = c; + } + } + + // Cross-check against Gradle's own view of the app runtime classpath, and + // use it to tell shipped native libraries from build tooling. + final runtime = {}; + if (runtimeClasspaths.isEmpty) { + problems.add( + 'No app runtime classpath supplied; cannot cross-check the snapshot ' + 'against Gradle resolution.', + ); + } + for (final text in runtimeClasspaths) { + final parsed = parseRuntimeClasspath(text); + runtime.addAll(parsed.resolved); + for (final f in parsed.failed) { + problems.add('Gradle could not resolve $f on the app runtime classpath.'); + } + } + if (runtimeClasspaths.isNotEmpty && runtime.isEmpty) { + problems.add('The supplied app runtime classpath lists no dependencies.'); + } + if (snapshots.isNotEmpty) { + problems.addAll( + checkRuntimeClasspathCoverage( + runtime: runtime, + snapshotKeys: { + for (final c in components.values) '${c.name}@${c.version}', + }, + ), + ); + } + if (runtime.isNotEmpty) { + for (final key in components.keys.toList()) { + final c = components[key]!; + components[key] = c.withScope( + runtime.contains('${c.name}@${c.version}') + ? 'app-runtime' + : 'build-tooling', + ); + } + } + + final pub = parsePubLockfile(pubLockfile); + if (pub.components.isEmpty) { + problems.add('pubspec.lock lists no hosted Pub packages.'); + } + for (final name in pub.unsupported) { + problems.add('Pub package $name uses a git source; it cannot be checked.'); + } + for (final c in pub.components) { + components[c.key] = c; + } + + if (pluginsFile == null) { + problems.add( + '.flutter-plugins-dependencies not found (run `make deps-lockfile`); ' + 'cannot cross-check plugin Android dependencies against the snapshot.', + ); + } else if (snapshots.isNotEmpty) { + problems.addAll( + checkPluginCoverage( + pluginsFileContent: pluginsFile, + snapshotCoordinates: { + for (final c in components.values) + if (c.source == 'gradle') c.name, + }, + readGradleFile: readGradleFile, + ), + ); + } + + final exceptions = parseExceptions(exceptionsContent, now); + problems.addAll(exceptions.problems); + + final findings = []; + var osvChecked = false; + final list = components.values.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + if (osv == null) { + problems.add('Advisory lookup was skipped (--offline); inventory only.'); + } else if (list.isNotEmpty) { + try { + final ids = await osv.queryBatch(list); + final byId = {}; + final records = >{}; + final unique = {for (final l in ids) ...l}; + final queue = unique.toList(); + Future worker() async { + while (queue.isNotEmpty) { + final id = queue.removeLast(); + records[id] = await osv.vulnerability(id); + } + } + + await Future.wait([for (var i = 0; i < 8; i++) worker()]); + for (var i = 0; i < list.length; i++) { + for (final id in ids[i]) { + final record = records[id]!; + if (record['withdrawn'] != null) continue; + final f = byId.putIfAbsent( + id, + () => Finding( + id, + [for (final a in (record['aliases'] as List?) ?? const []) '$a'], + severityOf(record), + '${record['summary'] ?? ''}', + ), + ); + f.components.add(list[i]); + } + } + for (final f in byId.values) { + for (final e in exceptions.active) { + final matches = + (e.advisory == f.id || f.aliases.contains(e.advisory)) && + f.components.any((c) => c.name == e.package); + if (matches) f.exception = e; + } + } + findings.addAll(byId.values); + osvChecked = true; + } on Object catch (e) { + problems.add('Advisory lookup failed: $e'); + } + } + + return Assessment( + commit: commit, + generatedAt: now, + problems: problems, + components: list, + findings: findings, + expired: exceptions.expired, + snapshots: [ + for (final s in snapshots) + { + 'file': s.file, + 'sha256': s.sha256Hex, + 'commit': s.json['sha'], + 'ref': s.json['ref'], + 'scanned': s.json['scanned'], + 'detector': s.json['detector'], + 'components': gradleComponents(s.json).length, + }, + ], + lockfileSha256: pubLockfileSha256, + osvChecked: osvChecked, + ); +} + +// --- CLI -------------------------------------------------------------------- + +Future> loadSnapshots(List paths) async { + final files = []; + for (final p in paths) { + if (FileSystemEntity.isDirectorySync(p)) { + files.addAll( + Directory(p) + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.json')), + ); + } else if (File(p).existsSync()) { + files.add(File(p)); + } + } + files.sort((a, b) => a.path.compareTo(b.path)); + final out = []; + for (final f in files) { + final decoded = jsonDecode(f.readAsStringSync()); + if (decoded is Map) { + out.add(SnapshotInfo(f.path, sha256File(f.path), decoded)); + } + } + return out; +} + +/// SHA-256 of a file via the platform tool (no package dependency needed). +String sha256File(String path) { + for (final cmd in const [ + ['sha256sum'], + ['shasum', '-a', '256'], + ]) { + try { + final r = Process.runSync(cmd.first, [ + ...cmd.skip(1), + path, + ], stdoutEncoding: utf8); + if (r.exitCode == 0) return (r.stdout as String).split(' ').first; + } on ProcessException { + continue; + } + } + return ''; +} + +Never _usage(String message) { + stderr + ..writeln(message) + ..writeln( + 'Usage: dart run tool/dependency_inventory.dart ' + ' --sha ' + '--gradle-snapshot --runtime-classpath ... ' + '[--lockfile pubspec.lock] ' + '[--plugins-file apps/playground/.flutter-plugins-dependencies] ' + '[--exceptions .github/dependency-exceptions.json] [--out dir] [--offline]', + ); + exit(64); +} + +Future main(List args) async { + if (args.isEmpty) _usage('Missing command.'); + final command = args.first; + if (command != 'validate-snapshot' && command != 'assess') { + _usage('Unknown command "$command".'); + } + final opts = >{}; + var offline = false; + for (var i = 1; i < args.length; i++) { + final a = args[i]; + if (a == '--offline') { + offline = true; + } else if (a.startsWith('--') && i + 1 < args.length) { + opts.putIfAbsent(a.substring(2), () => []).add(args[++i]); + } else { + _usage('Bad argument "$a".'); + } + } + String? one(String k) => opts[k]?.last; + + final sha = one('sha'); + if (sha == null || sha.isEmpty) _usage('--sha is required.'); + final snapshotPaths = opts['gradle-snapshot'] ?? const []; + final snapshots = await loadSnapshots(snapshotPaths); + final runtimeClasspaths = []; + for (final path in opts['runtime-classpath'] ?? const []) { + final f = File(path); + if (f.existsSync()) runtimeClasspaths.add(f.readAsStringSync()); + } + + if (command == 'validate-snapshot') { + final problems = [ + if (snapshots.isEmpty) 'No Gradle dependency snapshot found.', + for (final s in snapshots) + ...validateSnapshot(s.json, expectedSha: sha, label: s.file), + if (runtimeClasspaths.isEmpty) + 'No app runtime classpath supplied (--runtime-classpath).' + else + ...checkRuntimeClasspathCoverage( + runtime: { + for (final t in runtimeClasspaths) + ...parseRuntimeClasspath(t).resolved, + }, + snapshotKeys: { + for (final s in snapshots) + for (final c in gradleComponents(s.json)) + '${c.name}@${c.version}', + }, + ), + ]; + if (problems.isEmpty) { + stdout.writeln( + 'Snapshot OK: ${snapshots.length} file(s), ' + '${snapshots.expand((s) => gradleComponents(s.json)).length} components.', + ); + return; + } + for (final p in problems) { + stderr.writeln('::error::$p'); + } + exit(2); + } + + final lockfilePath = one('lockfile') ?? 'pubspec.lock'; + final pluginsPath = + one('plugins-file') ?? 'apps/playground/.flutter-plugins-dependencies'; + final exceptionsPath = + one('exceptions') ?? '.github/dependency-exceptions.json'; + final outDir = Directory(one('out') ?? 'dependency-evidence') + ..createSync(recursive: true); + + final lockFile = File(lockfilePath); + final plugins = File(pluginsPath); + final exceptionsFile = File(exceptionsPath); + final result = await assess( + commit: sha, + snapshots: snapshots, + pubLockfile: lockFile.existsSync() ? lockFile.readAsStringSync() : '', + pubLockfileSha256: lockFile.existsSync() ? sha256File(lockfilePath) : null, + pluginsFile: plugins.existsSync() ? plugins.readAsStringSync() : null, + runtimeClasspaths: runtimeClasspaths, + readGradleFile: (path) { + final f = File(path); + return f.existsSync() ? f.readAsStringSync() : ''; + }, + exceptionsContent: exceptionsFile.existsSync() + ? exceptionsFile.readAsStringSync() + : null, + osv: offline ? null : HttpOsvClient(), + now: DateTime.now().toUtc(), + ); + + const encoder = JsonEncoder.withIndent(' '); + File( + '${outDir.path}/assessment.json', + ).writeAsStringSync('${encoder.convert(result.toJson())}\n'); + File('${outDir.path}/assessment.md').writeAsStringSync(result.toMarkdown()); + File('${outDir.path}/inventory.json').writeAsStringSync( + '${encoder.convert({ + 'commit': sha, + 'components': [for (final c in result.components) c.toJson()], + })}\n', + ); + + stdout.write(result.toMarkdown()); + for (final p in result.problems) { + stderr.writeln('::error::$p'); + } + exit(switch (result.verdict) { + Verdict.pass => 0, + Verdict.findings => 1, + Verdict.incomplete => 2, + }); +} diff --git a/tool/dependency_inventory_test.dart b/tool/dependency_inventory_test.dart new file mode 100644 index 0000000..5a87f32 --- /dev/null +++ b/tool/dependency_inventory_test.dart @@ -0,0 +1,348 @@ +// Tests for tool/dependency_inventory.dart. Plain `dart` (no test package, so +// the workspace lockfile is untouched): run with `make test-tools`. +import 'dart:io'; + +import 'dependency_inventory.dart'; + +int _failures = 0; +int _passed = 0; + +void expectTrue(bool condition, String name) { + if (condition) { + _passed++; + } else { + _failures++; + stderr.writeln('FAIL: $name'); + } +} + +void expectEq(Object? actual, Object? expected, String name) { + expectTrue(actual == expected, '$name (got $actual, expected $expected)'); +} + +const sha = '7345fa5620bc9455a743e73131f926b69584b1e9'; + +Map snapshot({ + String? commit = sha, + int count = 120, + List extra = const [ + 'pkg:maven/com.android.tools.build/gradle@9.0.1?repository_url=x', + 'pkg:maven/org.jetbrains.kotlin/kotlin-gradle-plugin@2.3.20', + 'pkg:maven/io.flutter/flutter_embedding_release@1.0.0-abc', + 'pkg:maven/androidx.webkit/webkit@1.15.0', + ], +}) { + return { + 'version': 0, + 'sha': commit, + 'scanned': '2026-10-12T00:00:00Z', + 'manifests': { + 'm': { + 'resolved': { + for (var i = 0; i < count; i++) + 'g:a$i:1': {'package_url': 'pkg:maven/g/a$i@1'}, + for (final p in extra) p: {'package_url': p}, + }, + }, + }, + }; +} + +class FakeOsv implements OsvClient { + FakeOsv(this.vulns, {this.fail = false}); + final Map> vulns; + final bool fail; + + @override + Future>> queryBatch(List components) async { + if (fail) throw StateError('network down'); + return [ + for (final c in components) + if (vulns.isNotEmpty && c.name == 'androidx.webkit:webkit') + ['GHSA-test'] + else + [], + ]; + } + + @override + Future> vulnerability(String id) async => vulns[id]!; +} + +const lockfile = ''' +packages: + clock: + dependency: "direct main" + description: + name: clock + sha256: abc + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + local_thing: + dependency: "direct main" + description: + path: "../x" + relative: true + source: path + version: "1.0.0" +sdks: + dart: ">=3.12.0 <4.0.0" +'''; + +Future main() async { + // purl parsing + final c = componentFromPurl( + 'pkg:maven/androidx.webkit/webkit@1.15.0?repository_url=https%3A%2F%2Fdl.google.com', + )!; + expectEq(c.name, 'androidx.webkit:webkit', 'purl name'); + expectEq(c.version, '1.15.0', 'purl version'); + expectTrue(componentFromPurl('pkg:npm/left-pad@1.0.0') == null, 'non-maven'); + expectTrue(componentFromPurl('pkg:maven/nogroup') == null, 'malformed purl'); + + // pub lockfile: hosted only, sdk/path skipped + final pub = parsePubLockfile(lockfile); + expectEq(pub.components.length, 1, 'hosted pub package count'); + expectEq(pub.components.single.name, 'clock', 'hosted pub package'); + expectEq(pub.components.single.version, '1.1.3', 'hosted pub version'); + + // snapshot validation: good, stale, empty, too small, missing canary + expectEq(validateSnapshot(snapshot(), expectedSha: sha).length, 0, 'valid'); + expectTrue( + validateSnapshot( + snapshot(commit: 'deadbeef'), + expectedSha: sha, + ).any((p) => p.contains('stale')), + 'stale sha rejected', + ); + expectTrue( + validateSnapshot({ + 'sha': sha, + 'scanned': 'x', + 'manifests': {}, + }, expectedSha: sha).any((p) => p.contains('no manifests')), + 'empty manifests rejected', + ); + expectTrue( + validateSnapshot( + snapshot(count: 3), + expectedSha: sha, + ).any((p) => p.contains('empty or partial')), + 'too few components rejected', + ); + expectTrue( + validateSnapshot( + snapshot(extra: const ['pkg:maven/androidx.webkit/webkit@1.15.0']), + expectedSha: sha, + ).any((p) => p.contains('missing required coordinate')), + 'missing canary rejected', + ); + + // runtime classpath parsing + const tree = ''' +releaseRuntimeClasspath - Runtime classpath of '/release'. ++--- org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +| +--- org.jetbrains:annotations:13.0 -> 23.0.0 +| +--- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0 (c) +| \\--- org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0 (c) ++--- project :connectivity_plus +| +--- androidx.core:core:1.18.0 +| \\--- androidx.collection:collection -> 1.4.2 (*) +\\--- com.example:broken:1.0 FAILED +'''; + final rt = parseRuntimeClasspath(tree); + expectTrue( + rt.resolved.contains('org.jetbrains.kotlin:kotlin-stdlib@2.3.20'), + 'rt direct', + ); + expectTrue( + rt.resolved.contains('org.jetbrains:annotations@23.0.0'), + 'rt arrow', + ); + expectTrue( + rt.resolved.contains('androidx.collection:collection@1.4.2'), + 'rt versionless', + ); + expectTrue( + !rt.resolved.any((r) => r.contains('jdk8')), + 'rt skips constraints', + ); + expectEq(rt.failed.single, 'com.example:broken', 'rt failed node'); + expectEq( + checkRuntimeClasspathCoverage( + runtime: {'a:b@1', 'c:d@2'}, + snapshotKeys: {'a:b@1'}, + ).length, + 1, + 'runtime coverage gap detected', + ); + + // plugin coverage + const plugins = + '{"plugins":{"android":[{"name":"p","path":"/p/","native_build":true}]}}'; + String gradle(String path) => + 'dependencies { implementation("androidx.webkit:webkit:1.15.0")\n' + ' testImplementation("junit:junit:4.13.2")\n' + ' classpath("com.android.tools.build:gradle:\$v") }'; + expectEq( + checkPluginCoverage( + pluginsFileContent: plugins, + snapshotCoordinates: {'androidx.webkit:webkit'}, + readGradleFile: gradle, + ).length, + 0, + 'plugin deps covered (test deps and variables ignored)', + ); + expectTrue( + checkPluginCoverage( + pluginsFileContent: plugins, + snapshotCoordinates: {}, + readGradleFile: gradle, + ).any((p) => p.contains('androidx.webkit:webkit')), + 'plugin dep missing from graph detected', + ); + expectTrue( + checkPluginCoverage( + pluginsFileContent: '{"plugins":{"android":[]}}', + snapshotCoordinates: {}, + readGradleFile: gradle, + ).isNotEmpty, + 'no android plugins is a problem', + ); + + // severity + expectEq( + cvssV3Score('CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'), + 9.8, + 'cvss 9.8', + ); + expectEq( + cvssV3Score('CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H'), + 10.0, + 'cvss 10', + ); + expectEq( + severityOf({ + 'database_specific': {'severity': 'MODERATE'}, + }), + 'MEDIUM', + 'moderate is medium', + ); + expectEq( + severityOf({ + 'severity': [ + { + 'type': 'CVSS_V3', + 'score': 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + }, + ], + }), + 'CRITICAL', + 'cvss fallback', + ); + expectEq(severityOf({}), 'UNRATED', 'no data is unrated'); + expectTrue(isBlocking('UNRATED'), 'unrated blocks'); + expectTrue(!isBlocking('MEDIUM'), 'medium does not block'); + + // exceptions + final now = DateTime.utc(2026, 10, 12); + final ex = parseExceptions(''' +{"exceptions": [ + {"advisory":"GHSA-test","package":"androidx.webkit:webkit","reason":"r","approvedBy":"@a","expires":"2026-12-01"}, + {"advisory":"GHSA-old","package":"p","reason":"r","approvedBy":"@a","expires":"2026-01-01"}, + {"advisory":"GHSA-far","package":"p","reason":"r","approvedBy":"@a","expires":"2030-01-01"}, + {"advisory":"GHSA-incomplete"} +]}''', now); + expectEq(ex.active.length, 1, 'one active exception'); + expectEq(ex.expired.length, 1, 'one expired exception'); + expectEq(ex.problems.length, 2, 'too-far and incomplete rejected'); + expectTrue(parseExceptions('{', now).problems.isNotEmpty, 'bad JSON flagged'); + + // end-to-end verdicts + final snap = SnapshotInfo('s.json', 'x', snapshot()); + final runtimeText = '+--- androidx.webkit:webkit:1.15.0\n'; + Future run({ + OsvClient? osv, + String? exceptions, + String? commit, + List? runtimeTexts, + }) => assess( + commit: commit ?? sha, + snapshots: [snap], + pubLockfile: lockfile, + pluginsFile: plugins, + runtimeClasspaths: runtimeTexts ?? [runtimeText], + readGradleFile: gradle, + exceptionsContent: exceptions, + osv: osv, + now: now, + ); + final high = { + 'GHSA-test': { + 'id': 'GHSA-test', + 'summary': 's', + 'database_specific': {'severity': 'HIGH'}, + }, + }; + + final clean = await run(osv: FakeOsv({})); + expectEq( + clean.verdict, + Verdict.pass, + 'clean inventory passes ${clean.problems}', + ); + + final found = await run(osv: FakeOsv(high)); + expectEq(found.verdict, Verdict.findings, 'high advisory -> findings'); + expectEq( + found.findings.single.scope, + 'app-runtime', + 'finding scoped to app runtime', + ); + + final excepted = await run( + osv: FakeOsv(high), + exceptions: + '{"exceptions":[{"advisory":"GHSA-test","package":"androidx.webkit:webkit","reason":"r","approvedBy":"@a","expires":"2026-12-01"}]}', + ); + expectEq(excepted.verdict, Verdict.pass, 'active exception unblocks'); + expectTrue( + excepted.findings.single.exception != null, + 'exception stays visible', + ); + + final down = await run(osv: FakeOsv({}, fail: true)); + expectEq(down.verdict, Verdict.incomplete, 'lookup failure is incomplete'); + expectEq(down.toJson()['cleanResult'], false, 'incomplete is never clean'); + + final offline = await run(osv: null); + expectEq(offline.verdict, Verdict.incomplete, 'offline is incomplete'); + + final stale = await run(osv: FakeOsv({}), commit: 'f' * 40); + expectEq(stale.verdict, Verdict.incomplete, 'stale snapshot is incomplete'); + + final noRuntime = await run(osv: FakeOsv({}), runtimeTexts: const []); + expectEq( + noRuntime.verdict, + Verdict.incomplete, + 'missing runtime classpath is incomplete', + ); + + final gap = await run( + osv: FakeOsv({}), + runtimeTexts: ['+--- not.in:snapshot:1.0\n'], + ); + expectEq( + gap.verdict, + Verdict.incomplete, + 'graph/Gradle mismatch is incomplete', + ); + + stdout.writeln('$_passed passed, $_failures failed'); + if (_failures > 0) exit(1); +}