Skip to content

Offer re-encrypting the database with stronger encryption if a vault … #68

Offer re-encrypting the database with stronger encryption if a vault …

Offer re-encrypting the database with stronger encryption if a vault … #68

Workflow file for this run

name: Security
# The security report: every check that says something about whether this
# build can be trusted, in one run with one summary. Pushes and pull requests
# get the fast pass; the weekly schedule and a manual run raise the fuzz
# budget by two orders of magnitude and go through the packaged binary.
#
# Jobs:
# fuzz property-based fuzzing of every parser at a trust boundary
# (tests/fuzz) plus the hardening invariants pinned in
# tests/hardening-invariants.test.ts
# native-fuzz libFuzzer over the C++ addons' own parsing (electron/native),
# under AddressSanitizer and UndefinedBehaviorSanitizer: no
# bounds checks there, and what they read comes from a driver,
# a removable device and the keychain
# codeql GitHub's static analysis, results land in the Security tab
# audit the lockfile against the OSV advisory database
# dependency-review on pull requests: new dependencies with known
# vulnerabilities or unacceptable licenses block the merge
# packaged builds the Linux app and checks the bytes that ship: the
# fuse wire matches the config, the app boots and answers IPC
# reproducible builds the app twice, on different runner images and from
# different checkout paths, and fails unless both produce
# the same app.asar byte for byte
#
# Findings from fuzz, invariants, the native fuzzer, fuses and boot are
# uploaded as SARIF, so
# they appear as code scanning alerts in the Security tab beside CodeQL's
# and resolve on their own once a later run no longer reports them.
# Dependency alerts come from Dependabot, a repository setting rather than
# a workflow, and live in the same tab. There is no dependabot.yml on
# purpose: version-update pull requests are not wanted, and alerts do not
# need one.
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
schedule:
# Mondays, 06:00 UTC
- cron: '0 6 * * 1'
workflow_dispatch:
inputs:
fuzz_runs:
description: 'Iterations per fuzz property'
required: false
default: '20000'
native_fuzz_seconds:
description: 'Seconds of native (libFuzzer) fuzzing'
required: false
default: '900'
permissions:
contents: read
env:
# Deep on the schedule and on demand; a push gets a budget that keeps the
# job under a few minutes but still runs every property
FUZZ_RUNS: ${{ github.event_name == 'schedule' && '20000' || github.event.inputs.fuzz_runs || '2000' }}
# The native target runs millions of inputs a second, so a push still gets
# a real pass; the schedule gets a quarter of an hour
NATIVE_FUZZ_SECONDS: ${{ github.event_name == 'schedule' && '900' || github.event.inputs.native_fuzz_seconds || '60' }}
jobs:
fuzz:
name: Fuzz and hardening invariants
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci --no-audit --no-fund
# Both runs write a JSON report alongside the console output; a failure
# is recorded and the job carries on so the report reaches the Security
# tab, then fails at the end
- name: Hardening invariants
id: invariants
run: npx vitest run tests/hardening-invariants.test.ts --reporter=verbose --reporter=json --outputFile=invariants.json --includeTaskLocation
continue-on-error: true
- name: Fuzz
id: fuzz
run: |
echo "fuzz budget: $FUZZ_RUNS runs per property"
npx vitest run tests/fuzz --reporter=verbose --reporter=json --outputFile=fuzz.json --includeTaskLocation 2>&1 | tee fuzz.log
exit "${PIPESTATUS[0]}"
continue-on-error: true
- name: Convert findings to SARIF
if: always()
run: node scripts/sarif-report.mjs vitest invariants.json fuzz.json --out fuzz.sarif
# Alerts land under Security > Code scanning with this category; an
# upload with no results resolves the previous run's alerts. Forks
# cannot write security events, so the upload is allowed to fail there
- name: Upload to the Security tab
if: always()
uses: github/codeql-action/upload-sarif@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4
with:
sarif_file: fuzz.sarif
category: fuzz-and-invariants
continue-on-error: true
- name: Report
if: always()
run: |
{
echo "## Fuzz and hardening invariants"
echo
echo "Budget: \`$FUZZ_RUNS\` runs per property. Seeds are printed on failure; reproduce with \`FUZZ_SEED=<seed> FUZZ_RUNS=$FUZZ_RUNS npx vitest run <file>\`. Failures are also filed under Security > Code scanning, category \`fuzz-and-invariants\`."
echo
echo '```'
grep -E "✓|✗|×|Test Files|Tests |Counterexample|seed" fuzz.log | sed 's/\x1b\[[0-9;]*m//g' | head -120
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail on findings
if: steps.invariants.outcome == 'failure' || steps.fuzz.outcome == 'failure'
env:
INVARIANTS: ${{ steps.invariants.outcome }}
FUZZ: ${{ steps.fuzz.outcome }}
run: |
echo "hardening invariants: $INVARIANTS, fuzz: $FUZZ"
exit 1
# Both runners: the Touch ID addon's string handling is Objective-C, so the
# macOS leg is the only one that fuzzes it rather than compiling it out.
# Apple's clang has no libFuzzer; the runner uses the Homebrew LLVM the
# image ships instead. Every instrumentation it tries is probed by running
# it, since on macOS 26 that LLVM's AddressSanitizer builds a binary that
# hangs at startup; the run reports which sanitizers it ended up with
native-fuzz:
name: Native addon fuzz (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
# The image ships a Homebrew LLVM (llvm@20 on macOS 26, llvm@18 on 15);
# this only installs one if an image change ever drops it
- name: Ensure a libFuzzer-capable clang
if: runner.os == 'macOS'
run: ls "$(brew --prefix)/opt" | grep -q '^llvm' || brew install llvm
# The targets include nothing but the addons' own headers, so neither
# pcsclite nor node-gyp is needed here
- name: Fuzz the native addons
id: native
run: |
echo "native fuzz budget: ${NATIVE_FUZZ_SECONDS}s per target"
node scripts/fuzz-native.mjs --seconds "$NATIVE_FUZZ_SECONDS"
continue-on-error: true
- name: Convert findings to SARIF
if: always()
run: node scripts/sarif-report.mjs native native-fuzz-out/pcsc.log native-fuzz-out/touchid.log --out native-fuzz.sarif
# A category per runner: an upload replaces the previous one under the
# same name, so one shared category would resolve the other leg's alerts
- name: Upload to the Security tab
if: always()
uses: github/codeql-action/upload-sarif@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4
with:
sarif_file: native-fuzz.sarif
category: native-fuzz-${{ matrix.os }}
continue-on-error: true
# The input that reproduced it is the whole reproduction, and it is a few
# bytes; without it the stack in the log is all anyone has to go on
- name: Keep the reproducer
if: steps.native.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: native-fuzz-reproducer-${{ matrix.os }}
path: |
native-fuzz-out/*/crash-*
native-fuzz-out/*/leak-*
native-fuzz-out/*/timeout-*
native-fuzz-out/*/oom-*
native-fuzz-out/*.log
if-no-files-found: ignore
# The runner name reaches the script through env, where the shell quotes
# it; substituted into the script it would be code
- name: Report
if: always()
env:
RUNNER_OS_LABEL: ${{ matrix.os }}
run: |
{
echo "## Native addon fuzz ($RUNNER_OS_LABEL)"
echo
echo "Budget: \`${NATIVE_FUZZ_SECONDS}\`s per target under AddressSanitizer and UndefinedBehaviorSanitizer. Findings are filed under Security > Code scanning, category \`native-fuzz-$RUNNER_OS_LABEL\`, and the reproducing input is attached to the run."
echo
echo '```'
grep -hE "^compiler:|^mode:|^instrumentation:|^passed over:|ERROR|SUMMARY|runtime error|Assertion|^Done |^replayed |stat::number_of_executed_units" native-fuzz-out/*.log 2>/dev/null | head -40 || echo "no log"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail on findings
if: steps.native.outcome == 'failure'
run: exit 1
codeql:
name: CodeQL
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4
with:
languages: javascript-typescript
queries: security-extended
config-file: ./.github/codeql/config.yml
- name: Analyze
uses: github/codeql-action/analyze@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4
with:
category: /language:javascript-typescript
- name: Report
if: always()
run: |
{
echo "## CodeQL"
echo
echo "Findings, if any, are in the Security tab under Code scanning (category \`/language:javascript-typescript\`, query suite \`security-extended\`)."
} >> "$GITHUB_STEP_SUMMARY"
audit:
name: Dependency audit
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# OSV carries the same advisories npm audit reads (GitHub's database
# among its sources) behind its own API, so an npm registry outage no
# longer fails the report or stalls it for minutes. The whole lockfile
# is scanned, build tooling included: a compromised bundler ships as
# surely as a compromised runtime dependency
- name: Scan the lockfile against OSV
id: osv
uses: google/osv-scanner-action/osv-scanner-action@6e4298ebc4db23e847df9b2e2de2939d6f066c67 # v2.5.1
with:
scan-args: |-
--lockfile=package-lock.json
--format=sarif
--output=osv.sarif
continue-on-error: true
- name: Upload to the Security tab
if: always()
uses: github/codeql-action/upload-sarif@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4
with:
sarif_file: osv.sarif
category: dependencies
continue-on-error: true
- name: Report
if: always()
run: |
node -e '
const fs = require("fs");
const lines = ["## Dependency audit (OSV)", ""];
if (!fs.existsSync("osv.sarif")) {
lines.push("The scanner produced no report; see the scan step.");
} else {
const results = JSON.parse(fs.readFileSync("osv.sarif", "utf8")).runs.flatMap(r => r.results ?? []);
lines.push(results.length === 0
? "No known vulnerabilities in package-lock.json."
: `${results.length} finding(s), filed under Security > Code scanning, category \`dependencies\`:`);
for (const r of results.slice(0, 40)) lines.push(`- ${r.ruleId}: ${(r.message?.text ?? "").split("\n")[0].slice(0, 160)}`);
}
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join("\n") + "\n\n");
'
- name: Fail on findings
if: steps.osv.outcome == 'failure'
run: exit 1
dependency-review:
name: Dependency review
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Review dependency changes
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
comment-summary-in-pr: on-failure
packaged:
name: Packaged binary
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libnss3 libsecret-1-dev libusb-1.0-0-dev libudev-dev libpcsclite-dev pkgconf xvfb
# An unpacked directory is enough to inspect and boot; the AppImage
# wrapping adds minutes and changes nothing these checks look at
- name: Package
run: |
npx tsc -b
npx vite build
node electron/build.mjs
node electron/copy-native-modules.mjs
npx electron-builder --config electron-builder.config.js --linux dir --publish never
env:
NODE_ENV: production
- name: Fuse wire matches the configuration
id: fuses
run: |
node scripts/check-fuses.mjs 2>&1 | tee fuses.log
exit "${PIPESTATUS[0]}"
continue-on-error: true
- name: Boot smoke test
id: smoke
run: |
xvfb-run -a node scripts/smoke-boot.mjs -- --ozone-platform=x11 2>&1 | tee smoke.log
exit "${PIPESTATUS[0]}"
continue-on-error: true
- name: Convert findings to SARIF
if: always()
run: |
node scripts/sarif-report.mjs fuses fuses.log --out fuses.sarif
node scripts/sarif-report.mjs smoke smoke.log --out smoke.sarif
- name: Upload fuse findings to the Security tab
if: always()
uses: github/codeql-action/upload-sarif@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4
with:
sarif_file: fuses.sarif
category: packaged-fuses
continue-on-error: true
- name: Upload boot findings to the Security tab
if: always()
uses: github/codeql-action/upload-sarif@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4
with:
sarif_file: smoke.sarif
category: packaged-boot
continue-on-error: true
- name: Report
if: always()
run: |
{
echo "## Packaged binary"
echo
echo "Failures are also filed under Security > Code scanning, categories \`packaged-fuses\` and \`packaged-boot\`."
echo
echo '```'
cat fuses.log 2>/dev/null || echo "fuse check did not run"
echo
cat smoke.log 2>/dev/null || echo "smoke test did not run"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail on findings
if: steps.fuses.outcome == 'failure' || steps.smoke.outcome == 'failure'
env:
FUSES: ${{ steps.fuses.outcome }}
BOOT: ${{ steps.smoke.outcome }}
run: |
echo "fuses: $FUSES, boot: $BOOT"
exit 1
reproducible:
name: Reproducible asar (${{ matrix.image }})
runs-on: ${{ matrix.image }}
timeout-minutes: 30
strategy:
matrix:
include:
# Two images and two working directories: a hash that survives
# both is one a third party can expect to reproduce
- image: ubuntu-latest
checkout: .
- image: ubuntu-24.04
checkout: elsewhere/vigil-rebuild
defaults:
run:
working-directory: ${{ matrix.checkout }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: ${{ matrix.checkout }}
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: ${{ matrix.checkout }}/package-lock.json
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libnss3 libsecret-1-dev libusb-1.0-0-dev libudev-dev libpcsclite-dev pkgconf
- name: Package
run: |
npx tsc -b
npx vite build
node electron/build.mjs
node electron/copy-native-modules.mjs
npx electron-builder --config electron-builder.config.js --linux dir --publish never
env:
NODE_ENV: production
- name: Hash the asar
run: node scripts/asar-hash.mjs dist --write asar.sha256
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: asar-hash-${{ matrix.image }}
path: ${{ matrix.checkout }}/dist/asar.sha256
if-no-files-found: error
reproducible-compare:
name: Reproducible asar (compare)
needs: reproducible
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: asar-hash-*
path: hashes
- name: Compare
run: |
find hashes -name asar.sha256 -exec cat {} +
distinct=$(find hashes -name asar.sha256 -exec cat {} + | sort -u | wc -l)
{
echo "## Reproducible asar"
echo
echo '```'
find hashes -name asar.sha256 -exec cat {} +
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
if [ "$distinct" != "1" ]; then
echo "the two builds produced different app.asar files"
exit 1
fi