From b801d46ddc68340758975ef1aa70b01761bbdc4e Mon Sep 17 00:00:00 2001 From: Andrew Kent Date: Wed, 23 Sep 2026 13:16:59 -0400 Subject: [PATCH] add codeql and enhance dependency analyzer runs --- .github/workflows/ci.yml | 39 +++++ .github/workflows/codeql.yml | 45 +++++ .github/workflows/dependency-submission.yml | 4 +- AGENTS.md | 4 + build.gradle | 24 +++ .../gradle/CheckDependenciesTask.groovy | 105 +++++++++++ .../gradle/ShippedDependenciesTask.groovy | 58 ++++++ mise.toml | 15 ++ scripts/check-codeql.sh | 47 +++++ scripts/check-dependencies.py | 165 ------------------ 10 files changed, 340 insertions(+), 166 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 buildSrc/src/main/groovy/dev/braintrust/gradle/CheckDependenciesTask.groovy create mode 100644 buildSrc/src/main/groovy/dev/braintrust/gradle/ShippedDependenciesTask.groovy create mode 100644 mise.toml create mode 100755 scripts/check-codeql.sh delete mode 100755 scripts/check-dependencies.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7983a912..f644f312 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,42 @@ jobs: with: name: test-results path: build/test-results/test/ + + dependency-check: + name: Dependency advisories + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + permissions: + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + with: + java-version: 17 + distribution: 'temurin' + + - name: Check shipped dependencies + id: dependencies + # Check the full shipped graph, including advisories that also affect main. + env: + GH_TOKEN: ${{ github.token }} + run: ./gradlew checkDependencies --console=plain + + - name: Report dependency scan outcome + if: always() + env: + SCAN_OUTCOME: ${{ steps.dependencies.outcome }} + run: | + echo '## Dependency advisories' >> "$GITHUB_STEP_SUMMARY" + if [ "$SCAN_OUTCOME" = 'success' ]; then + echo 'No matching GitHub-reviewed advisories were found in shipped dependencies.' >> "$GITHUB_STEP_SUMMARY" + else + echo '::error::Dependency scan found advisories or could not complete. Review the Check shipped dependencies step logs.' + echo 'The scan found advisories or could not complete. Review the **Check shipped dependencies** step logs; this is not a clean scan.' >> "$GITHUB_STEP_SUMMARY" + fi + echo 'This check covers the full shipped dependency graph, not just dependencies changed by this PR. Findings or scan errors fail the check.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..4403eadb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,45 @@ +name: CodeQL + +on: + workflow_dispatch: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: '43 4 * * *' # Daily at 04:43 UTC. + +permissions: + contents: read + +jobs: + analyze: + name: Analyze Java + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up JDK 17 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + with: + java-version: 17 + distribution: 'temurin' + + - name: Initialize CodeQL + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + languages: java-kotlin + build-mode: manual + + - name: Build Java for analysis + # CodeQL must observe compilation, not reuse cached outputs or an existing daemon. + run: ./gradlew compileJava --no-daemon --no-build-cache --rerun-tasks + + - name: Analyze + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + category: '/language:java-kotlin' diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index 942b553a..394b3ce6 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -4,6 +4,8 @@ on: workflow_dispatch: push: branches: [ main ] + schedule: + - cron: '17 3 * * *' # Daily at 03:17 UTC. permissions: contents: read @@ -34,7 +36,7 @@ jobs: - name: Submit shipped dependencies uses: gradle/actions/dependency-submission@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - # Shared with scripts/check-dependencies.py, including the plugin version. + # Shipped dependency filters are shared with the checkDependencies Gradle task. # Includes the agent's internal runtime graph behind its shaded JAR. with: cache-provider: basic diff --git a/AGENTS.md b/AGENTS.md index 0b87a848..7a71a692 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,10 @@ VCR_MODE=record ./gradlew :braintrust-sdk:test --tests 'dev.braintrust.devserver - **`braintrust-api` is generated code.** don't edit sources under it by hand; it's regenerated from the braintrust openapi spec pinned as `braintrustOpenApiRef` in gradle.properties. - **there are no version constants to bump.** the sdk version is derived from git tags at build time (`generateVersion()` in build.gradle) and written into braintrust.properties. "bump the version" is not a source change. - When adding test cases, favor adding to the test file of the module being changed rather than making a new file. For example, if you fix a bug in the `Foo` module, add the test case to `FooTest.java` instead of making a new file, `FooTestMyBuggyCase.java` +- **run CodeQL once as a final check for code changes:** `./gradlew checkCodeQL` (first-time setup: `mise trust && mise install`). don't rerun it after every edit; rerun when needed to verify a security fix. the task fails on any finding or scan error; review the printed findings and generated SARIF report. +- **use judgment when resolving CodeQL findings.** investigate the flagged code path and fix genuine vulnerabilities at the source. don't suppress alerts, weaken checks, or distort correct code just to make findings disappear. if a finding appears to be a false positive or there is a good reason not to follow its recommendation, tell the user which finding, the evidence and security tradeoff, and your proposed disposition. get their agreement before suppressing or dismissing it; don't silently ignore it. +- **run dependency checker when changing dependencies:**: `./gradlew checkDependencies` +- **don't add new build tools without approval** favor using java/gradle/groovy/bash for misc scripts and tools. Favor using the existing ecosystem instead of introducing new build dependencies. If you think a new build dependency is worth it, ask for approval to add it. ## Releasing diff --git a/build.gradle b/build.gradle index 9823934c..8ffc4888 100644 --- a/build.gradle +++ b/build.gradle @@ -136,3 +136,27 @@ task installGitHooks(type: Exec) { group = 'Build Setup' commandLine 'bash', 'scripts/install-hooks.sh' } + +// Shipped dependency filters are shared with the dependency-submission workflow. +def shippedFilters = new groovy.json.JsonSlurper().parse(file('.github/dependency-graph.json')) +def shippedProjects = subprojects.findAll { it.path ==~ shippedFilters.DEPENDENCY_GRAPH_INCLUDE_PROJECTS } +shippedProjects.each { shipped -> + // Resolve each graph in its own project; cross-project resolution is an error in Gradle 9. + shipped.tasks.register('shippedDependencies', dev.braintrust.gradle.ShippedDependenciesTask) { task -> + shipped.configurations + .findAll { it.canBeResolved && it.name ==~ shippedFilters.DEPENDENCY_GRAPH_INCLUDE_CONFIGURATIONS } + .each { task.rootComponents.put(it.name, it.incoming.resolutionResult.rootComponent) } + task.outputFile = shipped.layout.buildDirectory.file('shipped-dependencies.txt') + } +} + +tasks.register('checkDependencies', dev.braintrust.gradle.CheckDependenciesTask) { + shippedDependencies.from(shippedProjects.collect { it.tasks.named('shippedDependencies') }) +} + +tasks.register('checkCodeQL', Exec) { + group = 'verification' + description = 'Runs the local CodeQL scan using the mise-pinned toolchain' + workingDir rootDir + commandLine 'mise', 'exec', '--', './scripts/check-codeql.sh' +} diff --git a/buildSrc/src/main/groovy/dev/braintrust/gradle/CheckDependenciesTask.groovy b/buildSrc/src/main/groovy/dev/braintrust/gradle/CheckDependenciesTask.groovy new file mode 100644 index 00000000..dadab0a1 --- /dev/null +++ b/buildSrc/src/main/groovy/dev/braintrust/gradle/CheckDependenciesTask.groovy @@ -0,0 +1,105 @@ +package dev.braintrust.gradle + +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.TaskAction + +import java.util.concurrent.Callable +import java.util.concurrent.Executors + +abstract class CheckDependenciesTask extends DefaultTask { + + /** Coordinate lists written by each shipped project's {@link ShippedDependenciesTask}. */ + @InputFiles + abstract ConfigurableFileCollection getShippedDependencies() + + CheckDependenciesTask() { + group = 'verification' + description = 'Checks shipped dependencies against GitHub-reviewed advisories (requires authenticated gh)' + // Advisories change independently of the inputs, so never treat a previous scan as up to date. + outputs.upToDateWhen { false } + } + + @TaskAction + void checkDependencies() { + logger.lifecycle('Resolving shipped dependencies from the current working tree (including uncommitted changes)...') + def packages = shippedPackages() + logger.lifecycle("Checking ${packages.size()} shipped dependency versions against GitHub-reviewed advisories...") + + def executor = Executors.newFixedThreadPool(6) + List results + try { + def futures = packages.collect { coordinate -> + executor.submit({ -> CheckDependenciesTask.advisories(coordinate) } as Callable) + } + // An API failure must never be reported as a clean or complete scan. + results = futures.collect { it.get() } + } catch (Exception e) { + throw new GradleException("Dependency scan incomplete: ${e.cause?.message ?: e.message}", e) + } finally { + executor.shutdownNow() + } + + def findings = results.findAll { !it.advisories.isEmpty() } + if (findings.isEmpty()) { + logger.lifecycle("PASS: No matching GitHub-reviewed advisories in ${packages.size()} shipped dependency versions.") + return + } + findings.each { finding -> + logger.lifecycle("\n${finding.coordinate}") + finding.advisories.each { advisory -> + logger.lifecycle(" ${advisory.severity.toUpperCase(Locale.ROOT)} ${advisory.cve_id ?: advisory.ghsa_id}: ${advisory.summary}") + logger.lifecycle(" ${advisory.html_url}") + } + } + int count = findings.sum { it.advisories.size() } + throw new GradleException("${count} advisory matches across ${findings.size()} shipped dependency versions.") + } + + private SortedSet shippedPackages() { + def packages = new TreeSet() + shippedDependencies.files.each { file -> + file.readLines().findAll { !it.isBlank() }.each { packages.add(it) } + } + if (packages.isEmpty()) { + throw new GradleException('The shipped dependency graph is empty; refusing to report a clean scan.') + } + return packages + } + + private static Map advisories(String coordinate) { + def stdout = new StringBuilder() + def stderr = new StringBuilder() + def process = new ProcessBuilder([ + 'gh', 'api', '--hostname', 'github.com', '--method', 'GET', + '--paginate', '--slurp', '/advisories', + '-f', 'type=reviewed', '-f', 'ecosystem=maven', + '-f', "affects=${coordinate}".toString(), '-f', 'per_page=100' + ]).start() + try { + process.waitForProcessOutput(stdout, stderr) + if (process.exitValue() != 0) { + throw new GradleException("gh exited with status ${process.exitValue()} for ${coordinate}:\n${stdout}\n${stderr}") + } + } finally { + process.destroy() + } + def pages = new JsonSlurper().parseText(stdout.toString()) + if (!(pages instanceof List) || pages.any { !(it instanceof List) }) { + throw new GradleException("Invalid GitHub advisory response for ${coordinate}") + } + def active = [:] + pages.flatten().each { advisory -> + if (!(advisory instanceof Map) || !advisory.ghsa_id) { + throw new GradleException("Invalid GitHub advisory for ${coordinate}") + } + if (!advisory.withdrawn_at) { + active[advisory.ghsa_id] = advisory + } + } + return [coordinate: coordinate, advisories: active.values().sort { it.ghsa_id }] + } +} diff --git a/buildSrc/src/main/groovy/dev/braintrust/gradle/ShippedDependenciesTask.groovy b/buildSrc/src/main/groovy/dev/braintrust/gradle/ShippedDependenciesTask.groovy new file mode 100644 index 00000000..ea6ddc36 --- /dev/null +++ b/buildSrc/src/main/groovy/dev/braintrust/gradle/ShippedDependenciesTask.groovy @@ -0,0 +1,58 @@ +package dev.braintrust.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.artifacts.result.ResolvedComponentResult +import org.gradle.api.artifacts.result.ResolvedDependencyResult +import org.gradle.api.artifacts.result.UnresolvedDependencyResult +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Writes the external module coordinates of a project's shipped configurations, one per line. + * + * Registered per project so each graph is resolved in its owning project's context. + */ +abstract class ShippedDependenciesTask extends DefaultTask { + + /** Root component of each shipped configuration, keyed by configuration name. */ + @Input + abstract MapProperty getRootComponents() + + @OutputFile + abstract RegularFileProperty getOutputFile() + + ShippedDependenciesTask() { + description = 'Lists the external dependency versions shipped by this project' + } + + @TaskAction + void writeCoordinates() { + def packages = new TreeSet() + rootComponents.get().each { configuration, root -> + def seen = new HashSet() + def pending = new ArrayDeque([root]) + while (!pending.isEmpty()) { + def component = pending.poll() + if (!seen.add(component)) { + continue + } + if (component.id instanceof ModuleComponentIdentifier) { + def id = (ModuleComponentIdentifier) component.id + packages.add("${id.group}:${id.module}@${id.version}".toString()) + } + component.dependencies.each { dependency -> + if (dependency instanceof UnresolvedDependencyResult) { + throw new GradleException("Cannot scan ${path}:${configuration}", dependency.failure) + } + pending.add(((ResolvedDependencyResult) dependency).selected) + } + } + } + outputFile.get().asFile.text = packages.collect { it + '\n' }.join('') + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..b6eb6b1f --- /dev/null +++ b/mise.toml @@ -0,0 +1,15 @@ +[tool_alias] +codeql = "github:github/codeql-action" + +# The bundle pins the CLI and standard query packs together. +[tools.codeql] +version = "2.27.0" +version_prefix = "codeql-bundle-v" +strip_components = 1 +bin_path = "." + +[tools.codeql.platforms] +linux-x64 = { asset_pattern = "codeql-bundle-linux64.tar.gz" } +linux-arm64 = { asset_pattern = "codeql-bundle-linux-arm64.tar.gz" } +macos-x64 = { asset_pattern = "codeql-bundle-osx64.tar.gz" } +macos-arm64 = { asset_pattern = "codeql-bundle-osx64.tar.gz" } diff --git a/scripts/check-codeql.sh b/scripts/check-codeql.sh new file mode 100755 index 00000000..7ded68af --- /dev/null +++ b/scripts/check-codeql.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if ! command -v codeql >/dev/null 2>&1; then + echo "CodeQL is required. Run 'mise install', then 'mise exec -- ./scripts/check-codeql.sh'." >&2 + exit 2 +fi + +mkdir -p "$ROOT/build/codeql" +SCAN_DIR="$(mktemp -d "$ROOT/build/codeql/scan.XXXXXX")" +echo "CodeQL database and results: $SCAN_DIR" + +# Match CI: capture fresh compilation, including generated sources. +# Invoke the wrapper's Java entry point directly: macOS system shells can break tracing. +codeql database create "$SCAN_DIR/java-db" \ + --language=java \ + --source-root="$ROOT" \ + --command='java -classpath gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain compileJava --no-daemon --no-build-cache --rerun-tasks' + +codeql database analyze "$SCAN_DIR/java-db" \ + java-code-scanning.qls \ + --format=sarif-latest \ + --output="$SCAN_DIR/results.sarif" \ + --sarif-category='/language:java-kotlin' \ + --threads=0 + +# Reuse the computed results; CSV has one record per alert and no header. +# This lets us fail on findings without another JSON parser dependency. +codeql database interpret-results "$SCAN_DIR/java-db" \ + java-code-scanning.qls \ + --format=csv \ + --output="$SCAN_DIR/results.csv" + +printf '\nCodeQL scan complete. Results: %s/results.sarif\n' "$SCAN_DIR" +echo "Open the report in a SARIF viewer, or import java-db into the CodeQL VS Code extension." +echo "Nothing was uploaded." + +if [ -s "$SCAN_DIR/results.csv" ]; then + echo "FAIL: CodeQL reported findings:" >&2 + cat "$SCAN_DIR/results.csv" >&2 + exit 1 +fi + +echo "PASS: CodeQL reported no findings." diff --git a/scripts/check-dependencies.py b/scripts/check-dependencies.py deleted file mode 100755 index 754f4da0..00000000 --- a/scripts/check-dependencies.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -"""Check the current working tree's shipped dependencies against GitHub advisories.""" - -import argparse -from concurrent.futures import ThreadPoolExecutor -import json -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile -from urllib.parse import unquote - - -ROOT = Path(__file__).resolve().parent.parent -INIT_SCRIPT = """ -initscript { - repositories { maven { url = uri('https://plugins.gradle.org/m2/') } } - dependencies { - classpath "org.gradle:github-dependency-graph-gradle-plugin:${System.getenv('DEPENDENCY_GRAPH_PLUGIN_VERSION')}" - } -} - -// JVM properties override the sanitized environment, including inherited exclusions. -System.properties.stringPropertyNames().findAll { key -> - key.startsWith('DEPENDENCY_GRAPH_') || key.startsWith('GITHUB_DEPENDENCY_GRAPH_') -}.each { key -> System.clearProperty(key) } - -apply plugin: org.gradle.github.GitHubDependencyGraphPlugin - -// The graph plugin can omit unresolved dependencies. Refuse a partial scan. -gradle.projectsEvaluated { - def root = gradle.rootProject - root.tasks.register('checkLocalDependencyResolution') { - dependsOn ':ForceDependencyResolutionPlugin_resolveAllDependencies' - doLast { - root.allprojects.each { project -> - if (project.path ==~ System.getenv('DEPENDENCY_GRAPH_INCLUDE_PROJECTS')) { - project.configurations.each { config -> - if (config.canBeResolved && - config.name ==~ System.getenv('DEPENDENCY_GRAPH_INCLUDE_CONFIGURATIONS')) { - config.incoming.resolutionResult.allDependencies.each { dependency -> - if (dependency instanceof org.gradle.api.artifacts.result.UnresolvedDependencyResult) { - throw new GradleException("Cannot scan ${project.path}:${config.name}", dependency.failure) - } - } - } - } - } - } - } - } -} -""" - - -def run(command, **kwargs): - result = subprocess.run( - command, cwd=ROOT, text=True, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, **kwargs - ) - if result.returncode: - detail = "\n".join(part.strip() for part in (result.stdout, result.stderr) if part.strip()) - raise RuntimeError(f"{command[0]} exited with status {result.returncode}:\n{detail}") - return result.stdout - - -def shipped_packages(): - settings = json.loads((ROOT / '.github/dependency-graph.json').read_text()) - # Inherited submission settings must not silently exclude local dependencies. - environment = { - key: value for key, value in os.environ.items() - if not key.startswith(('DEPENDENCY_GRAPH_', 'GITHUB_DEPENDENCY_GRAPH_')) - } - environment.update(settings) - with tempfile.TemporaryDirectory(prefix='braintrust-dependencies-') as directory: - temporary = Path(directory) - init_script = temporary / 'scan.init.gradle' - init_script.write_text(INIT_SCRIPT) - environment.update({ - 'GITHUB_DEPENDENCY_GRAPH_JOB_CORRELATOR': 'local-check', - 'GITHUB_DEPENDENCY_GRAPH_JOB_ID': 'local-check', - 'GITHUB_DEPENDENCY_GRAPH_REF': 'refs/heads/local-check', - 'GITHUB_DEPENDENCY_GRAPH_SHA': run(['git', 'rev-parse', 'HEAD']).strip(), - 'GITHUB_DEPENDENCY_GRAPH_WORKSPACE': str(ROOT), - 'DEPENDENCY_GRAPH_REPORT_DIR': str(temporary / 'reports'), - }) - run([ - str(ROOT / 'gradlew'), '--quiet', '--console=plain', - '--no-configuration-cache', '--no-configure-on-demand', - '-I', str(init_script), ':checkLocalDependencyResolution', - ], env=environment) - snapshot = json.loads((temporary / 'reports/local-check.json').read_text()) - packages = sorted({ - dependency['package_url'] - for manifest in snapshot['manifests'].values() - for dependency in manifest['resolved'].values() - if 'package_url' in dependency - }) - if not packages: - raise RuntimeError('The shipped dependency graph is empty; refusing to report a clean scan.') - return packages - - -def advisories(package_url): - if not package_url.startswith('pkg:maven/'): - raise ValueError(f'Unsupported dependency ecosystem: {package_url}') - coordinate = package_url[len('pkg:maven/'):].split('?', 1)[0].split('#', 1)[0] - group, artifact_version = coordinate.split('/', 1) - artifact, version = artifact_version.rsplit('@', 1) - affected = f'{unquote(group)}:{unquote(artifact)}@{unquote(version)}' - pages = json.loads(run([ - 'gh', 'api', '--hostname', 'github.com', '--method', 'GET', - '--paginate', '--slurp', '/advisories', - '-f', 'type=reviewed', '-f', 'ecosystem=maven', - '-f', f'affects={affected}', '-f', 'per_page=100', - ])) - active = { - advisory['ghsa_id']: advisory - for page in pages for advisory in page - if not advisory.get('withdrawn_at') - } - return affected, sorted(active.values(), key=lambda advisory: advisory['ghsa_id']) - - -def scan(packages): - print(f'Checking {len(packages)} shipped dependency versions against GitHub-reviewed advisories...', flush=True) - # Collect every result before reporting success; an API failure is not a clean scan. - with ThreadPoolExecutor(max_workers=6) as executor: - results = list(executor.map(advisories, packages)) - findings = [(package, matches) for package, matches in results if matches] - if not findings: - print(f'PASS: No matching GitHub-reviewed advisories in {len(packages)} shipped dependency versions.') - return 0 - for package, matches in findings: - print(f'\n{package}') - for advisory in matches: - identifier = advisory.get('cve_id') or advisory['ghsa_id'] - print(f" {advisory['severity'].upper()} {identifier}: {advisory['summary']}") - print(f" {advisory['html_url']}") - count = sum(len(matches) for _, matches in findings) - print(f'\nFAIL: {count} advisory matches across {len(findings)} shipped dependency versions.') - return 1 - - -def main(): - parser = argparse.ArgumentParser( - description=__doc__, - epilog='Requires Python 3.9+, JDK 17, and authenticated gh. Exit codes: 0 clean, 1 findings, 2 scan error. Nothing is submitted to GitHub.', - ) - parser.parse_args() - try: - for executable in ('java', 'git', 'gh'): - if not shutil.which(executable): - raise RuntimeError(f'Required command not found: {executable}') - print('Resolving shipped dependencies from the current working tree (including uncommitted changes)...', flush=True) - return scan(shipped_packages()) - except (OSError, RuntimeError, ValueError, KeyError, TypeError) as error: - print(f'ERROR: Dependency scan incomplete: {error}', file=sys.stderr) - return 2 - - -if __name__ == '__main__': - sys.exit(main())