Repository navigation
274 lines (247 loc) · 13.8 KB
/
Copy pathsecurity.yml
File metadata and controls
274 lines (247 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# Ported from community PR https://github.com/TestSprite/testsprite-cli/pull/220
# (author @OkeyAmy), adapted to this repo's actual constraints:
#
# - No CodeQL job. This repo (testsprite-cli-atlas) is a private Team-plan
# repo without GitHub Advanced Security purchased — the CodeQL Action
# (init/analyze) hard-fails here (`Code Security must be enabled for this
# repository to use code scanning`, verified against the live API before
# writing this file). CodeQL is free on the public mirror
# (TestSprite/testsprite-cli) instead, enabled there via GitHub's
# repository-level "default setup" — a SETTING, not a workflow file — so
# there is nothing here for the outbound snapshot to carry or exclude,
# and no risk of the two CodeQL configuration modes (default setup vs.
# workflow-based) conflicting on the public repo.
# - `dependency-review-action` requires Advanced Security on private repos,
# so this job runs only in the public mirror. Let the action fail when the
# public dependency graph is unavailable; a skipped scan must not be green.
# - `eslint-plugin-security` lints only files changed in this PR/push (see
# the `lint-security` job), not the whole tree: a full-tree run surfaces
# ~380 pre-existing findings (almost entirely
# `security/detect-non-literal-fs-filename` on ordinary local-path fs
# calls a config-file-reading CLI makes routinely — not a real
# vulnerability signal for this codebase shape).
#
# ESLint lints whole FILES, but the checked-in changed-line filter
# fails only on error findings on lines added by this diff. Existing
# findings on untouched lines are reported in JSON and ignored by that
# filter; do not apply count-based bulk suppressions before filtering,
# because replacing an old finding with a new one can keep the count
# constant and hide the new finding.
# - Secret scanning: this repo's `ci.yml` already runs a gitleaks
# WORKING-TREE scan on every PR/push (added by atlas #274, 2026-07). This
# file adds the complementary FULL-HISTORY scan from #220 as its own job,
# scoped to `push` only (not every PR iteration) — full-history scanning
# needs `fetch-depth: 0` and walks every past commit, which is too slow
# to run per-PR (the same tradeoff `docs/internal/cli-oss/
# supply-chain-hardening.md` §1.4 already made for `ci.yml`'s job) but is
# the only mode that catches a secret that was committed and later
# removed from the working tree. Uses the checksum-verified pinned-binary
# install pattern already established by `ci.yml`/`divergence-sentinel.yml`
# rather than `gitleaks/gitleaks-action` (#220's choice): that action
# requires a `GITLEAKS_LICENSE` for org-owned repos — this org owns both
# testsprite-cli repos, so "free mode" does not apply — and adding it
# would introduce a new third-party `uses:` entry that the public repo's
# `allowed_actions` allowlist does not carry.
# - No `pnpm-lock.yaml` (this is an npm-only repo — see package-lock.json)
# and no `sandbox/**` ignore additions (no such directory exists in this
# repo; #220 never explained the addition).
name: Security
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, dev, stg]
# Not auto-dispatched by anything (none of this file's jobs are required
# checks) — this is for an operator who wants the extra opinion on a wave
# branch by hand; see lint-security's workflow_dispatch base-resolution
# branch below for why a manual dispatch here is correct.
workflow_dispatch: {}
# Least-privilege default (same convention as ci.yml). No job below needs
# write; anything more must opt in locally.
permissions:
contents: read
jobs:
# ── 1. Dependency audit ────────────────────────────────────────────────────
# Blocks on HIGH/CRITICAL in production dependencies (what ships to users).
# Dev-only vulns are reported but do not fail the build — they never reach
# a user's machine.
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: 'npm'
- run: npm ci
- name: Audit production dependencies (blocking)
run: npm audit --omit=dev --audit-level=high
- name: Audit all dependencies (informational)
run: npm audit --audit-level=high || true
# ── 2. Dependency review on PRs (public mirror only) ──────────────────────
# Blocks PRs that introduce a new vulnerable package. Requires GitHub
# Advanced Security, which atlas (private) does not have — see header.
# This is exactly the point in the pipeline where a community PR against
# a new dependency actually lands, so gating to the public repo costs
# nothing.
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.repository == 'TestSprite/testsprite-cli'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
fail-on-severity: high
fail-on-scopes: runtime
# ── 3. ESLint with security rules (changed files only) ────────────────────
# See header for why this is diff-scoped rather than full-tree.
lint-security:
name: ESLint Security (changed files)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: 'npm'
- run: npm ci
- name: Determine changed TypeScript files under src/
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
: > "$RUNNER_TEMP/changed-ts-files.txt"
rm -f "$RUNNER_TEMP/lint-empty-reason.txt"
# Two different things can leave that list empty, and they must not
# read the same in the log. "A real diff against a real base found
# no src TypeScript changes" is normal and stays green. "We had no
# base to diff against, so we never looked" must NOT be a green
# no-op — a green check that inspected nothing is worse than no
# check, because it reads as coverage. So this step never just
# skips: it either resolves a real base to diff (possibly via a
# fallback) or it fails the job outright.
#
# A pull_request always supplies a base SHA and fetch-depth: 0 makes
# it resolvable, so an unusable base there means this job's own
# checkout/base resolution is broken — fail loudly instead of
# reporting a false green. A push can legitimately have no usable
# base (github.event.before is all-zeros on a first push to a
# branch, or unreachable after certain force-pushes); on push we
# fall back to a real diff against HEAD^ (the tip commit's own
# diff, reachable thanks to fetch-depth: 0) instead of skipping, and
# only fall back further — to linting the full tracked src/**/*.ts
# set — when HEAD itself has no parent (a genuine first commit).
if [ -n "${BASE_SHA:-}" ] && git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
RESOLVED_BASE="$BASE_SHA"
BASE_DESC="$BASE_SHA (event-provided base)"
elif [ "$EVENT_NAME" = "pull_request" ]; then
echo "::error title=Security lint broken::pull_request base SHA ('${BASE_SHA:-<empty>}') is empty or unreachable even with fetch-depth: 0. A pull_request always supplies a resolvable base, so this means base resolution is broken, not that there is nothing to check. Failing the job instead of reporting a false green." >&2
exit 1
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && git rev-parse --verify -q origin/dev >/dev/null 2>&1; then
# A manual dispatch (an operator running this by hand against a
# wave branch, say) supplies no base at all — diff against where
# this branch forked from dev, not HEAD^, so the lint covers the
# whole wave, not just its latest commit. fetch-depth: 0 above
# already makes origin/dev reachable.
RESOLVED_BASE="$(git merge-base HEAD origin/dev)"
BASE_DESC="$RESOLVED_BASE (fallback: workflow_dispatch supplied no base — merge-base with origin/dev instead)"
elif git rev-parse --verify -q HEAD^ >/dev/null 2>&1; then
RESOLVED_BASE="HEAD^"
BASE_DESC="HEAD^ (fallback: push event supplied no usable base SHA — was '${BASE_SHA:-<empty>}' — so diffing HEAD's own commit instead of skipping)"
else
RESOLVED_BASE=""
BASE_DESC="none (fallback: HEAD has no parent, a genuine first commit; linting the full tracked src/**/*.ts set instead of skipping)"
fi
echo "Base used: ${BASE_DESC}"
# Persist the resolved base for the lint step's changed-line filter
# (empty string on the full-tree fallback — the filter treats that as
# "keep every finding", so nothing new is hidden).
printf '%s' "${RESOLVED_BASE}" > "$RUNNER_TEMP/resolved-base.txt"
if [ -n "$RESOLVED_BASE" ]; then
git diff --name-only --diff-filter=ACMR -z "$RESOLVED_BASE" "$HEAD_SHA" -- ':(glob)src/**/*.ts' \
> "$RUNNER_TEMP/changed-ts-files.txt"
else
git ls-files -z -- ':(glob)src/**/*.ts' > "$RUNNER_TEMP/changed-ts-files.txt"
fi
# NUL-terminated, so a newline count (`wc -l`) would be wrong here —
# count NUL bytes instead (git filenames can never contain one, so
# this always equals the number of entries).
FILE_COUNT=$(tr -dc '\0' < "$RUNNER_TEMP/changed-ts-files.txt" | wc -c)
echo "Files matched: ${FILE_COUNT}"
if [ "$FILE_COUNT" -eq 0 ]; then
echo "::notice title=Security lint: genuinely nothing to check::${BASE_DESC} produced zero src/**/*.ts files. This is a real result (empty diff, or an empty tree), not a skip."
echo "EMPTY" > "$RUNNER_TEMP/lint-empty-reason.txt"
fi
# The lint + changed-line verdict lives in a script so the release
# pipeline can run the identical check on a publish snapshot before it
# is pushed (see the script header for why that preview is needed).
- name: Run security lint on changed files
env:
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -f "$RUNNER_TEMP/lint-empty-reason.txt" ]; then
echo "Zero files to lint (see the previous step's log for which base was used and why) — nothing to lint, reporting success on a genuine empty result, not a skip."
exit 0
fi
bash .github/scripts/security-lint-changed.sh \
"$RUNNER_TEMP/changed-ts-files.txt" \
"$(cat "$RUNNER_TEMP/resolved-base.txt")" \
"$HEAD_SHA"
# ── 4. Full-history secret scan (push only) ────────────────────────────────
# Complements ci.yml's working-tree gitleaks job (every PR/push) with the
# one thing that job structurally cannot see: a secret that was committed
# and later removed. See header for why this doesn't also run per-PR.
gitleaks-history:
name: Secret scan — full history (gitleaks)
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0 # full history required for a history-mode scan
# Same pinned version + checksum-verified install as ci.yml /
# divergence-sentinel.yml — see those files for why this pin.
- name: Install gitleaks
env:
GITLEAKS_VERSION: '8.28.0'
run: |
set -euo pipefail
BASE="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -sSL -o "$TARBALL" "${BASE}/${TARBALL}"
curl -sSL -o checksums.txt "${BASE}/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
grep " ${TARBALL}\$" checksums.txt | sha256sum -c -
tar -xzf "$TARBALL" gitleaks
sudo mv gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Scan full git history for secrets
run: gitleaks detect --no-banner --redact --source .
approve-review-guard:
name: Approving review guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- name: Test approving review guard
run: node --test .github/scripts/check-approve-review-guard.test.mjs
- name: Check workflows and composite actions for approving reviews
run: node .github/scripts/check-approve-review-guard.mjs