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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions .github/workflows/launchpad-security-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,19 @@ name: launchpad — security audit
# matching. Off the hour to avoid GitHub's top-of-hour queue.
# workflow_dispatch manual reruns, and the mechanism used to capture the
# self-test evidence this task's PR links to.
# pull_request filtered to the paths the audit can actually be affected
# by — the audit scripts themselves, any workflow, and
# .gitignore, which the (future) ignore-coverage check
# reads. Unfiltered would run on every unrelated PR for no
# benefit; too narrow risks a check landing that no PR
# trigger ever exercises.
# pull_request UNFILTERED by path as of #67. #66 scoped this to
# ".github/**"/"launchpad/**"/".gitignore" because no
# check existed yet that cared about anything else.
# #67's secret scan has to see every file a PR touches —
# a leaked credential is exactly as real in crates/ or
# desktop/ as it is here — so a path filter narrow
# enough to be cheap is also narrow enough to miss the
# one thing this check exists to catch.
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch: {}
pull_request:
paths:
- ".github/**"
- "launchpad/**"
- "**/.gitignore"
pull_request: {}

# Read-only, and nothing more. No repository secret is referenced anywhere in
# this file — a check that needed one would be a check this workflow cannot run
Expand All @@ -47,12 +45,43 @@ jobs:
persist-credentials: false
fetch-depth: 0

# Pinned to a specific release and verified against gitleaks' own
# published checksum before extraction — this is the one third-party
# binary this workflow trusts, and it never runs with any credential
# in scope, so a supply-chain compromise here still cannot reach a
# secret. No gitleaks-maintained Action is used, to keep this
# workflow's trust surface to "one pinned, checksummed binary" rather
# than a third party's Action code at whatever version they push next.
- name: Install gitleaks (pinned, checksum-verified)
env:
GITLEAKS_VERSION: "8.30.1"
GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
run: |
set -euo pipefail
curl -fsSL -o gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c -
tar -xzf gitleaks.tar.gz gitleaks
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
rm gitleaks.tar.gz gitleaks
gitleaks version

# The harness's own controls, scoped to this task's tests only — the
# unfiltered `unittest discover` other launchpad-*.yml checks use would
# also run every unrelated script's test suite in this directory, which
# is duplicate work this workflow doesn't own and would blow the 3-minute
# budget #66 sets for no benefit.
# REQUIRE_GITLEAKS_RULESET turns test_security_audit_gitleaks_ruleset.py's
# "gitleaks not on PATH" skip into a failure. That suite is the only thing
# that proves .gitleaks.toml's rules still match anything, and it is the
# one suite here that needs the binary — so if the install step above ever
# breaks or is reordered, this job must go red rather than report success
# for a scan-ruleset check that silently did not run. Same reasoning as
# launchpad-agents-tests.yml's empty-discovery guard: a check that can be
# satisfied by absence is not a check.
- name: Run the harness's controls
env:
REQUIRE_GITLEAKS_RULESET: "1"
run: python3 -m unittest discover -s launchpad/scripts -p "test_security_audit*.py"

- name: Run the security audit
Expand Down
116 changes: 116 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
title = "launchpad-26/buzz secret-scanning config"

# Engine and allowlist location decided in ADR-0006 (launchpad/decisions/).
# Extends gitleaks' built-in ruleset rather than replacing it — SSH private
# keys, GitHub PATs and generic high-entropy .env-shaped assignments are
# already covered by the default rules (verified empirically against the
# fixtures in launchpad/scripts/security_audit_fixtures/secrets/ before this
# file was written). The rules below cover only what the default ruleset
# does not: Nostr key material, glibc crypt hashes, this fork's BUZZ_S3_*
# naming, and a Postgres URL with an embedded password.

[extend]
useDefault = true

[[rules]]
id = "nostr-nsec-private-key"
description = "Nostr nsec1 bech32-encoded private key"
regex = '''nsec1[023456789acdefghjklmnpqrstuvwxyz]{58}'''
tags = ["key", "nostr"]

[[rules]]
id = "buzz-private-key"
description = "BUZZ_PRIVATE_KEY or a similarly-named 64-hex-character private key assignment"
# Every group below is non-capturing (?:...). A capturing group here silently
# drops the finding entirely once [extend] useDefault = true merges in
# gitleaks' default ruleset — reproduced and isolated empirically before this
# file was written (a bare (a|b) group matches standalone but reports zero
# leaks the moment useDefault is active; (?:a|b) is unaffected). No known
# gitleaks issue was found describing this, so treat every future rule here
# the same way: no capturing groups, ever, unless secretGroup is set to use one.
regex = '''(?i)[A-Z0-9_]*(?:PRIVATE_KEY|NSEC|SECKEY)[A-Z0-9_]*\s*=\s*['"]?[0-9a-fA-F]{64}['"]?'''
tags = ["key", "nostr"]

[[rules]]
id = "glibc-crypt-hash"
# $1/$5/$6 optionally carry a rounds=N$ parameter before the salt; $y$
# (yescrypt) carries its own short encoded parameter block instead. Both
# shapes are one or two extra $-delimited segments before the final hash, so
# the middle is a repeated group rather than a fixed salt-then-hash count.
description = "glibc/Unix crypt hash ($1$, $5$, $6$, or $y$)"
regex = '''\$(?:1|5|6|y)\$(?:[./A-Za-z0-9=]+\$){1,2}[./A-Za-z0-9]{20,}'''
tags = ["password", "crypt"]

[[rules]]
id = "buzz-s3-minio-key"
description = "BUZZ_S3_ACCESS_KEY or BUZZ_S3_SECRET_KEY assignment"
# {6,} on the value, not \S+: launchpad/deploy/archived/runbooks/dev-deployment-SOP.md
# documents these as a markdown table cell, `` `BUZZ_S3_ACCESS_KEY=` ``, where the
# character immediately after = is a bare backtick — \S+ matched that one
# punctuation character as if it were a credential. Requiring a real value
# shape excludes it without an allowlist entry, and still matches every
# fixture and every real assignment this rule exists for.
regex = '''BUZZ_S3_(?:ACCESS|SECRET)_KEY\s*=\s*['"]?[A-Za-z0-9_\-]{6,}['"]?'''
tags = ["key", "s3", "minio"]
# .env.example is, by repository-wide convention, entirely placeholder
# values meant to be copied and replaced — verified against .env.example
# and deploy/compose/.env.example, both CHANGE_ME-or-dev-value templates,
# never a deployed .env. Scoped to that one filename shape, not the repo.
[rules.allowlist]
paths = ['''.*\.env\.example$''']

[[rules]]
id = "postgres-url-with-password"
description = "Postgres connection URL with an embedded password"
regex = '''postgres(?:ql)?://[^:@/\s]+:[^@\s]+@[^\s]+'''
tags = ["password", "postgres"]
# The overwhelming majority of matches on this codebase are the standard
# local dev/test credential postgres://buzz:buzz_dev@localhost:5432/buzz
# (verified: scripts/run-tests.sh, crates/buzz-db/src/usage.rs, and ~100
# more files as of the first full-history scan run for #67) — a local-only
# connection string is not a production credential regardless of what the
# password looks like. Scoped to the host being loopback, not to any file
# or the value itself, so a real leaked URL pointed at a real host still
# fires. scripts/run-tests.sh already marks this same string
# `# sadscan:disable np.postgres.1` for a different, Block-internal
# scanner; this is gitleaks' equivalent for the same known-safe value.
[rules.allowlist]
regexes = ['''postgres(?:ql)?://[^:@/\s]+:[^@\s]+@(?:localhost|127\.0\.0\.1)(?::\d+)?/''']

# Fixtures prove every rule above fires (#67's own definition of done) and
# must therefore be readable by the scanner, but must never themselves be
# reported as a finding of the live audit — they are synthetic, not leaked.
#
# #67 as filed named two placeholders in dev-deployment-SOP.md (an
# ssh-ed25519 public-key fragment at a since-renumbered line, and a $6$
# prefix mentioned in prose) as needing an exclusion. Verified against the
# file's current content and location
# (launchpad/deploy/archived/runbooks/dev-deployment-SOP.md — moved out of
# launchpad/deploy/ entirely by the deploy-method archival that landed after
# #67 was filed): neither of those two strings matches any rule in this
# config any more, so neither needs an entry here. What the file's current
# content does trip is `<64 hex characters>` — a literal bracketed
# placeholder idiom this SOP uses for "a value goes here", not a value
# itself — via gitleaks' own default generic-api-key rule.
[allowlist]
description = "Paths this scan reads but never reports on, and one documentation placeholder idiom"
paths = [
'''launchpad/scripts/security_audit_fixtures/secrets/.*''',
# Same class of false positive .intersect/sadscan.yaml already documents
# for this exact file ("Cargo registry checksums are integrity hashes
# generated by Cargo, not payment data") — here it trips gitleaks'
# generic-api-key entropy heuristic instead of sadscan's payment-card
# rule, but the underlying content and reasoning are the same. Verified
# present in both Cargo.lock files in this repo (root and
# desktop/src-tauri/) — one finding each, both checksum data.
'''(^|/)Cargo\.lock$''',
]
regexes = [
'''<[A-Za-z0-9 ]+>''',
# dev-deployment-SOP.md:1079's worked-example "Public key:" value — a
# fixed, literal walkthrough value, explicitly labelled public (not the
# $6$/secret-key output on adjacent lines, which stay placeholder text
# and trip nothing). Matched literally, not by shape, so this entry
# cannot accidentally swallow any other 64-hex string anywhere else.
'''38980a43aba04331ba61b5e7b64b90e250cd411d042050eaf102a408acc6c379''',
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Fixture for #67. Synthetic (not derived from any real password/host) glibc
# crypt hashes, shaped correctly for the $6$ (SHA-512 crypt) and $y$
# (yescrypt) rules. Excluded from the audit's scan path in .gitleaks.toml.
SIX=$6$rounds=656000$Zq7wYkT2fixtureSalt$k4h2mNtQpXz9r1sJd8vLwYbGcHfEeUoAiSm3NkTpQrXyLzAaVbCcDdFfGgHhIiJjKkLlMm
Y=$y$j9T$fixtureSaltForY$K7pQmR9xTvLnZaWbCcDdEeFfGgHhIiJjKkLlMmNn
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Fixture for #67. A .env-shaped file with a real-looking (synthetic) secret
# value, to prove an .env-shaped assignment rule fires on file shape, not
# just on specific known variable names. Excluded from the audit's scan path
# in .gitleaks.toml.
SOME_SERVICE_API_TOKEN=fx_9Kj2mNpQr7VbXzYcWdEeFfGgHhIiJjKkLlMmNnOoPp
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Fixture for #67. Synthetic Nostr key material — a bech32 nsec shape and a
# 64-hex private-key shape, including the BUZZ_PRIVATE_KEY env-var name this
# fork uses. None of these decode to or were ever a real key. Excluded from
# the audit's scan path in .gitleaks.toml.
RELAY_OWNER_NSEC=nsec180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
BUZZ_PRIVATE_KEY=4c9f2e8a1b6d3f705e9c8a2b7d4f1e6c3a9b8d5f2e7c4a1b6d9f3e8c5a2b7d4f
BARE_HEX_KEY=9a3f7c2e5b8d1f4a6c9e2b5d8f1a4c7e9b2d5f8a1c4e7b9d2f5a8c1e4b7d9f2a
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Fixture for #67. A synthetic Postgres connection URL with an embedded
# password, matching the shape deploy/compose's POSTGRES_* variables could
# be assembled into. Excluded from the audit's scan path in .gitleaks.toml.
DATABASE_URL=postgres://buzzuser:fx7Kj2mNpQr7VbXzYcWd@db.internal:5432/buzz
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Fixture for #67. A synthetic GitHub personal-access-token shape, standing
# in for "container registry tokens" — ghcr.io auth uses a GitHub PAT, and
# gitleaks' default ruleset already recognizes the ghp_ prefix shape.
# Excluded from the audit's scan path in .gitleaks.toml.
GHCR_TOKEN=ghp_fx9Kj2mNpQr7VbXzYcWdEeFfGgHhIiJjKkLl00
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Fixture for #67. Synthetic S3/MinIO-shaped access-key and secret-key pair,
# matching this fork's BUZZ_S3_* naming (see deploy/compose/.env.example).
# Excluded from the audit's scan path in .gitleaks.toml.
BUZZ_S3_ACCESS_KEY=AKIAFX2K9MPQRSTUVWXY
BUZZ_S3_SECRET_KEY=fx7Kj2mNpQr7VbXzYcWdEeFfGgHhIiJjKkLlMmNn
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Fixture for #67. A real key, generated solely for this fixture, never used
# for any access anywhere, and excluded from the audit's scan path in
# .gitleaks.toml. Proves the SSH-private-key rule (gitleaks' built-in
# "private-key" rule) actually fires.
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDRTit6NTW83Hhmyqk0Vkh3dNEWE+JDMhx7fWFKhvs+fQAAAKCXixD+l4sQ
/gAAAAtzc2gtZWQyNTUxOQAAACDRTit6NTW83Hhmyqk0Vkh3dNEWE+JDMhx7fWFKhvs+fQ
AAAEDMfjdbOnF3oFC9jYn1YcZ9OZLggtFCfdJsbMclndkw4dFOK3o1NbzceGbKqTRWSHd0
0RYT4kMyHHt9YUqG+z59AAAAF2ZpeHR1cmUtb25seS1uZXZlci11c2VkAQIDBAUG
-----END OPENSSH PRIVATE KEY-----
2 changes: 2 additions & 0 deletions launchpad/scripts/security_audit_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"""

from security_audit_selftest_check import run as harness_self_test
from security_audit_secrets_check import run as secret_material_scan

CHECKS = [
harness_self_test,
secret_material_scan,
]
Loading
Loading