-
Notifications
You must be signed in to change notification settings - Fork 155
302 lines (276 loc) · 16.3 KB
/
Copy pathsecurity.yml
File metadata and controls
302 lines (276 loc) · 16.3 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
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# 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` has the same Advanced-Security requirement on
# private repos, so its job only executes once this file is running in
# the public mirror (see the job's own `if:`) — it still exists as a
# no-op step on every atlas PR/push rather than failing one.
# - `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, though, so "changed files only" still dumps a
# legacy file's entire backlog the moment one line of it is touched — which
# is exactly what happened on the v0.6.0 release push (touching the ~11k-line
# `src/commands/test.ts` surfaced 128 pre-existing `detect-non-literal-fs-
# filename` errors and failed the job). Two layers fix that WITHOUT lowering
# any rule's severity or exempting the tree:
# (C) A committed `eslint-suppressions.security.json` baseline (ESLint's
# native bulk suppressions) records today's backlog, so pre-existing
# findings are not re-reported; a genuinely NEW fs call pushes the
# per-file count past the baseline and surfaces. It uses a dedicated
# filename (NOT ESLint's default `eslint-suppressions.json`) so the
# main `eslint .` lint — a different config that never enables these
# security rules — does not auto-read it and fail on "unused
# suppressions"; the security job passes `--suppressions-location`
# explicitly.
# (A) `.github/scripts/filter-changed-line-findings.mjs` then keeps only the
# remaining error findings that fall on lines THIS diff added/modified,
# so touching a legacy file never fails on its untouched lines.
# Net: every rule stays at its designed severity, new/changed code is held to
# it, and the existing tree is neither re-linted from scratch nor exempted.
# To retire baseline entries as they get fixed, run ESLint with
# `--prune-suppressions` in a maintenance PR (never regenerate it to hide new
# findings).
# - 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]
# 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
# ── 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 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
- name: Run security lint on changed files
run: |
set -euo pipefail
# The file list is NUL-separated end-to-end: git diff/ls-files -z ->
# file -> xargs -0. Git filenames may contain a literal newline (or
# start with `-`), so converting to newlines (as this step used to,
# via `tr '\0' '\n'` + `xargs -d '\n'`) lets a filename like
# "src/x.ts\n--no-error-on-unmatched-pattern\nnot-a-real-file.ts"
# split into a real path, an injected ESLint OPTION, and a
# nonexistent path — ESLint then exits 0 having never linted the
# malicious file. NUL is the one byte git filenames cannot contain,
# so `-0`/`-z` is the only splitting mode that is actually safe
# here (the previous comment on this step claiming xargs was doing
# "NUL/newline-safe" splitting was false: the `tr` upstream had
# already destroyed the NUL delimiters before xargs ever saw the
# list). The trailing `--` additionally stops ESLint's own option
# parser at the file-list boundary, so no filename starting with
# `-` can be misread as a flag.
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
FILE_COUNT=$(tr -dc '\0' < "$RUNNER_TEMP/changed-ts-files.txt" | wc -c)
echo "Linting ${FILE_COUNT} file(s)."
# Read the NUL-separated list into an array (NUL-safe, single ESLint
# invocation so `--format json` emits ONE array) and lint with:
# - the committed eslint-suppressions.security.json baseline (passed
# explicitly so the main `eslint .` lint never reads it) so the
# pre-existing backlog is not re-reported; and
# - --pass-on-unpruned-suppressions so FIXING a baselined finding in
# a changed file (its count drops) doesn't fail the job over a now-
# stale suppression entry (prune it in a separate maintenance pass).
# The trailing `--` stops ESLint's option parser at the file boundary.
mapfile -d '' -t FILES < "$RUNNER_TEMP/changed-ts-files.txt"
# ESLint exits 1 when it reports findings and 2 on a fatal (config/crash)
# error; only 2 is a real failure here, because the changed-line filter
# below — not ESLint's own exit — decides pass/fail. Capture the report
# even on exit 1, but surface a genuine crash.
set +e
npx eslint --config eslint.security.config.mjs \
--suppressions-location=eslint-suppressions.security.json \
--pass-on-unpruned-suppressions --format json -- "${FILES[@]}" \
> "$RUNNER_TEMP/eslint-report.json"
ESLINT_EXIT=$?
set -e
if [ "$ESLINT_EXIT" -gt 1 ]; then
echo "::error title=Security lint crashed::ESLint exited ${ESLINT_EXIT} (fatal error, not a lint finding)."
cat "$RUNNER_TEMP/eslint-report.json" || true
exit "$ESLINT_EXIT"
fi
# Fail only on findings that land on lines this change added/modified
# (see the header note and .github/scripts/filter-changed-line-findings.mjs).
RESOLVED_BASE="$(cat "$RUNNER_TEMP/resolved-base.txt")" \
HEAD_SHA="${{ github.sha }}" \
node .github/scripts/filter-changed-line-findings.mjs "$RUNNER_TEMP/eslint-report.json"
# ── 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 .