-
Notifications
You must be signed in to change notification settings - Fork 90
492 lines (438 loc) · 24.6 KB
/
Copy pathsecurity.yml
File metadata and controls
492 lines (438 loc) · 24.6 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
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
name: Security
on:
push:
branches: [master, dev, 'release/**']
pull_request:
branches: [master, dev, 'release/**']
schedule:
- cron: '17 2 * * *' # nightly DAST and fuzzing; :17 to avoid the on-the-hour stampede
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
permissions:
contents: read
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
jobs:
codeql:
name: "SAST: CodeQL"
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
global-json-file: global.json
# No config-file, and specifically no `paths-ignore`. One was added here to keep the
# 638 generated files under obj/generated out of the analysis; it changed nothing,
# because paths-ignore applies to interpreted languages and to compiled ones analysed
# without a build, and this job builds C#. What CodeQL analyses is what the build
# compiled, so the only way to narrow it here is to compile less — which would mean
# not analysing the platform. Generated alerts are separated where they can be: in
# the backlog job below, which gates on the ones somebody wrote.
- uses: github/codeql-action/init@c4dd10e44af883a891fe31ced449bcb4a6728b9b # v3
with:
languages: csharp
queries: security-and-quality
- run: dotnet build FlowX.slnx --configuration Release
- uses: github/codeql-action/analyze@c4dd10e44af883a891fe31ced449bcb4a6728b9b # v3
with:
category: /language:csharp
semgrep:
name: "SAST: Semgrep"
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Scan
# OWASP + C# rulesets. --error makes any ERROR-severity finding fail the job;
# a security scanner that only warns is a scanner nobody reads.
#
# This job had failed on every run for months — 75 blocking findings, none of
# which anyone had looked at since the first few. That is worse than not having
# the job: it hid a real run-shell-injection finding in ci.yml for the whole of
# that time. One rule id is excluded by name below to bring the count to zero, so
# that the next finding is a change in colour rather than one more line in a log
# nobody opens. Excluding a rule is not the same as accepting what it looks for, so
# it says what took its place.
#
# csharp.lang.security.sqli — 20 findings, every one of them false. It matches
# the assignment rather than the expression, so `command.CommandText = Acquire;`
# with Acquire a `const string` reads to it exactly as an interpolation would,
# and that is the shape of all twenty. Replaced by SqlFitnessTests in
# tests/FlowX.Architecture.Tests, which parses the expression and fails on
# anything not settled at build time. That gate is strictly stronger than this
# rule was: it also reads `new NpgsqlCommand(sql, connection)`, which the rule
# never looked at, and it follows SQL arriving through a private helper's
# parameter back to the constants its call sites pass.
#
# yaml.github-actions.security.github-actions-mutable-action-tag was excluded here
# for 53 findings, one per `uses:`. All 64 are pinned to 40-character SHAs now and
# ci.yml's `Every action is pinned to a commit` step keeps them that way, so the
# exclusion is gone and the rule runs.
run: >-
semgrep scan
--config=p/owasp-top-ten
--config=p/csharp
--config=p/secrets
--exclude-rule=csharp.lang.security.sqli.csharp-sqli.csharp-sqli
--error
secrets:
name: Secret scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # gitleaks scans history, not just the diff
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
dependencies:
name: "SCA: vulnerable packages"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
global-json-file: global.json
- name: Restore
run: dotnet restore FlowX.slnx
- name: Fail on any known vulnerability
# FlowX.Abstractions has zero dependencies by construction (ADR-0009), so
# this job's job is mostly to keep it that way as other projects arrive.
run: |
dotnet list FlowX.slnx package --vulnerable --include-transitive 2>&1 | tee vuln.log
if grep -qE '>\s+\S+\s+\S+\s+\S+\s+(Low|Moderate|High|Critical)' vuln.log; then
echo "::error::Vulnerable package detected (OWASP A06)."
exit 1
fi
- name: Deprecated packages are a warning, not a failure
run: dotnet list FlowX.slnx package --deprecated || true
# THE OTHER DEPENDENCY TREE, which this job did not know existed. Everything above
# reads NuGet. samples/crm-web resolves 441 npm packages — 16 of them into the bundle a
# browser downloads — and no job in this workflow so much as mentioned them. gitleaks
# scans the client's committed files like any others, and Semgrep's rulesets read its
# TypeScript, but neither of those is a software-composition scan: a vulnerable
# transitive dependency is not a string in the repository, it is a version in a
# lockfile, and nothing was reading it.
#
# No `npm ci`: `npm audit` resolves the tree from package-lock.json, so this needs the
# lockfile and the network and not a node_modules.
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: The client ships no vulnerable package
working-directory: samples/crm-web
# `--omit=dev`, and this is the gate with teeth: the production closure is what ends
# up in dist/assets and runs in somebody's browser. Sixteen packages, zero findings
# today, and `--audit-level=low` means any finding at all fails — the same "any known
# vulnerability" posture the NuGet step above takes.
run: npm audit --omit=dev --audit-level=low
- name: The client's build-time tree carries nothing new
working-directory: samples/crm-web
# The dev tree cannot be gated the same way today and saying so is the point.
# `npm audit` reports six findings against it, all five distinct advisories reaching
# it down one path: vitest 2.1.9 depends on vite ^5.0.0, vite 5 pins esbuild 0.21,
# and no release on either line carries the fixes. They are a dev server that answers
# cross-origin requests, a Vitest UI nobody starts, and two Windows path bugs — none
# of them present in anything published, and none of them fixable without moving to
# Vitest 4, which is a test-runner migration and not a security patch.
#
# So the five are named, with their reason, and everything else fails. This is the
# shape the Semgrep job above uses for the two rules it excludes, and it is chosen
# over `--audit-level=high` for the reason that job gives at length: a threshold that
# swallows a class of finding hides the next one in that class, whereas a named
# exclusion makes the sixth advisory a change in colour.
run: |
npm audit --json > "$RUNNER_TEMP/npm-audit.json" || true
python3 - "$RUNNER_TEMP/npm-audit.json" <<'PY'
import json, sys
# GHSA id -> why it is not failing this build. Delete an entry the day the tree
# stops matching it; add one only with a sentence like these.
KNOWN = {
'GHSA-67mh-4wv8-2f99':
'esbuild <=0.24.2 dev server answers any origin. Reached only through '
'vite 5, which vitest 2 pins; no vite 5 release ships esbuild 0.25.',
'GHSA-4w7w-66w2-5vf9': 'vite path traversal in optimized-deps .map handling — dev server only.',
'GHSA-fx2h-pf6j-xcff': 'vite server.fs.deny bypass on Windows alternate paths — dev server only.',
'GHSA-v6wh-96g9-6wx3': 'launch-editor NTLMv2 disclosure via UNC paths on Windows — dev server only.',
'GHSA-5xrq-8626-4rwp': 'Vitest UI server reads arbitrary files. Nothing here starts `vitest --ui`.',
}
report = json.load(open(sys.argv[1]))
found = {}
for entry in report.get('vulnerabilities', {}).values():
for via in entry.get('via', []):
if isinstance(via, dict):
found[via['url'].rsplit('/', 1)[-1]] = (via['severity'], via['title'])
unknown = sorted(set(found) - set(KNOWN))
for advisory in unknown:
severity, title = found[advisory]
print(f'::error::{advisory} ({severity}): {title}')
if unknown:
print('::error::A new advisory against the client. Fix it, or name it in this')
print('::error::step with the sentence that says why it cannot reach anything.')
sys.exit(1)
print(f'{len(found)} advisory/advisories, all named: {sorted(found)}')
PY
# The other half of dependency hygiene — licences, constraint C6 — is deliberately
# not here. `DependencyLicencesAreCompatible` reads the same transitive closure this
# job scans for vulnerabilities, out of obj/project.assets.json, and checks it
# against docs/DEPENDENCIES.md; it runs in the "Architecture fitness functions" step
# of ci.yml, where a developer also meets it on `dotnet test` before committing. A
# second copy of the rule as shell here would be the mistake quality.yml's `debt` job
# documents at length.
iac:
name: IaC scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Checkov
# Skipped cleanly while there are no manifests yet, rather than failing
# on an empty directory and training everyone to ignore this job.
run: |
if [ -d deploy ] || [ -d charts ]; then
pip install checkov
checkov -d . --framework kubernetes,helm,dockerfile --compact --quiet
else
echo "No IaC directories yet — nothing to scan."
fi
dast:
name: "DAST: OWASP ZAP"
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
# The action opens an issue for the alerts it finds, and the workflow's default grant is
# `contents: read` — so every failing run also logged `Resource not accessible by
# integration` from the issues API. A gate that cannot report its own findings reports
# them to nobody.
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Skip until a sample exists
id: guard
# This condition was `-f samples/ecommerce/Directory.Build.props || -d
# samples/ecommerce/src`. Neither path has ever existed, and neither was ever
# going to: the sample is a flat project directory with its own csproj, not a
# nested source tree with its own props file. So the guard evaluated false on
# every scheduled run, printed "not runnable yet (WP-10)" long after WP-10
# landed, and the whole job skipped while docs/21 §4 listed DAST as running
# nightly. It tested for the wrong thing rather than for a sample that runs.
run: |
if [ -f samples/ecommerce/Ecommerce.csproj ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::warning title=DAST did not run::samples/ecommerce/Ecommerce.csproj is missing, so there was nothing to scan."
fi
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
if: steps.guard.outputs.ready == 'true'
with:
global-json-file: global.json
- name: Run the sample
if: steps.guard.outputs.ready == 'true'
run: |
dotnet run --project samples/ecommerce --configuration Release &
for i in $(seq 1 30); do
curl -sf http://localhost:5000/health && break
sleep 2
done
- name: The scan has a subject
if: steps.guard.outputs.ready == 'true'
# This job was scanning a 404 and calling it 66 passes.
#
# The target was http://localhost:5000, the sample serves nothing at `/`, and the
# automation log said so — `spider error accessing URL http://localhost:5000 status
# code returned : 404 expected 200`. Sixty-six passive rules then ran over an error
# page, none of them touching a generated endpoint, and the one warning they raised
# was about the caching headers on the 404. A DAST gate whose subject is absent is
# the shape this repository keeps finding; asserting the target answers is what
# turns "scanned nothing" into a failure instead of a green tick with a note.
run: |
status="$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:5000/health)"
if [ "$status" != "200" ]; then
echo "::error::/health answered $status, so ZAP would scan an error page."
exit 1
fi
echo "/health answers 200; the scan has something to read."
- name: ZAP baseline scan
if: steps.guard.outputs.ready == 'true'
uses: zaproxy/action-baseline@66042c8e7e24680119199a017e5b0e8603bf4dae # v0.12.0
with:
# /health rather than `/`, because that is a URL this application has. What the
# spider can reach from it is one GET: the rest of the surface is POST-only, and
# a baseline spider cannot exercise a POST. That limit is stated rather than
# papered over — the generated endpoints' behaviour is asserted by
# tests/FlowX.Http.Tests, and closing it here means an API scan driven by the
# manifest, which is its own work item.
target: http://localhost:5000/health
rules_file_name: .zap/rules.tsv
# A DAST finding against a sample is a platform defect until proven
# otherwise — the HTTP surface under test is generated code.
fail_action: true
backlog:
name: Code scanning backlog
runs-on: ubuntu-latest
# Runs after CodeQL, because the count it reads is the one CodeQL just uploaded.
needs: codeql
permissions:
contents: read
security-events: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: The backlog has not grown
# WHAT THIS EXISTS FOR. `github/codeql-action/analyze` uploads every alert the
# security-and-quality suite raises and fails on none of them, so the Security
# workflow has been green over a page nobody reads. Semgrep is the mirror image —
# it fails the job and uploads nothing — which means the only findings anybody has
# ever had to act on are Semgrep's, and CodeQL's have accumulated unread.
#
# A count with a committed ceiling is the smallest thing that changes that: a new
# alert is a red job on the pull request that introduced it, and the backlog can
# only be argued upward in writing.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# WHICH REF IS COUNTED, AND WHY IT IS NOT ALWAYS THIS RUN'S.
#
# With no `ref` at all the endpoint answers for the default branch, which is how
# this job reported the same 988 on four consecutive commits — it was never
# reading the tree under test, so a pull request could not move the number it
# gates on. Naming the ref fixed that for pushes and exposed the other half:
# `refs/pull/19/merge` came back with zero alerts three seconds after CodeQL
# finished analysing it. A pull request analysis is diffed against its base and
# surfaced as annotations on the pull request; it is not persisted as a set of
# open alerts the way a branch's is, so there is nothing here to count.
#
# So the ceiling is enforced where alerts persist — on pushes to master and dev,
# which is where a regression has to be caught before a release. On a pull
# request the same numbers are printed for the BASE branch as context, and the
# job cannot fail: gating a pull request on its base's backlog would fail it for
# something it did not do. The pull-request-time signal is CodeQL's own check,
# which annotates alerts this branch introduced.
REF: ${{ github.event_name == 'pull_request' && format('refs/heads/{0}', github.base_ref) || github.ref }}
GATED: ${{ github.event_name != 'pull_request' }}
run: |
set -o pipefail
# Three attempts, because alert ingestion can lag the analysis that produced it
# and an empty first answer is not evidence of an empty backlog.
for attempt in 1 2 3; do
gh api --paginate \
"/repos/${GITHUB_REPOSITORY}/code-scanning/alerts?state=open&per_page=100&ref=${REF}" \
> alerts.json
[ "$(jq 'length' alerts.json)" != "0" ] && break
echo "No alerts for ${REF} on attempt ${attempt}; waiting for ingestion."
sleep 20
done
python3 - <<'PY'
import json, os, sys
from collections import Counter
alerts = json.load(open('alerts.json'))
ref = os.environ['REF']
gated = os.environ['GATED'] == 'true'
if not alerts:
# CodeQL analysed this ref minutes ago, so zero is a ref that holds no
# persisted alerts, never a clean tree. Reporting it as clean would be the
# one kind of green this repository refuses to print.
print(f'::error::No alerts at all for {ref} after three attempts. That is a '
'ref this endpoint does not answer for, not an empty backlog.')
sys.exit(1)
def path_of(alert):
return alert.get('most_recent_instance', {}).get('location', {}).get('path', '?')
def generated(path):
return '/obj/' in path or path.endswith('.g.cs')
def tree(path):
return path.split('/', 1)[0] if '/' in path else '(root)'
def is_test(path):
return tree(path) == 'tests'
baseline = json.load(open('.github/code-scanning-baseline.json'))
not_gated = baseline.get('notGated', [])
def excused(alert):
"""Named in notGated for this rule AND this file, with a reason recorded there."""
path = path_of(alert)
return any(
entry['rule'] == alert['rule']['id']
and any(path == p or path.startswith(p) for p in entry['paths'])
for entry in not_gated)
# THE SPLIT IS THE GATE, AND IT HAS TWO CUTS. 1,988 of the 2,234 alerts on this
# repository are raised against files under obj/generated — System.Text.Json's
# serialisers, FlowX's own plans and dispatchers, the regex matchers. Nobody can act
# on those: fixing one means changing a generator that emits the same shape
# deliberately, and CodeQL cannot be told to skip them, because paths-ignore does not
# apply to a compiled language that is built (see the init step above). A further 102
# sit under tests/. So the ceiling is over the code that actually ships, and the other
# two are counted, listed and left alone.
authored = [a for a in alerts if not generated(path_of(a))]
tests = [a for a in authored if is_test(path_of(a))]
examined = [a for a in authored if not is_test(path_of(a)) and excused(a)]
shipped = [a for a in authored if not is_test(path_of(a)) and not excused(a)]
total = len(shipped)
# AND THE TESTS ARE COUNTED AND SHOWN AND NOT GATED, for the reason the generated
# files above are. A finding in a test is a finding in code that never leaves this
# repository: no deployment runs it, no caller reaches it, and the fixture that
# trips `cs/path-combine` is a string literal naming a file in the working tree.
# Holding a release to a style finding in a fixture is what teaches people to raise
# the ceiling, which is the one move that ends this gate. They stay visible below,
# so a real defect in a test is still readable — it is simply not what blocks a ship.
print(f'open alerts: {len(alerts)} on {ref} ({total} shipped, '
f'{len(examined)} examined, {len(tests)} tests, '
f'{len(alerts) - len(authored)} generated)')
counts = Counter(a['rule'].get('security_severity_level')
or a['rule'].get('severity') or 'unknown' for a in shipped)
for level, n in sorted(counts.items()):
print(f' {level}: {n}')
by_rule = Counter(a['rule']['id'] for a in shipped)
print('\nshipped, by rule (top 15 of %d):' % len(by_rule))
for rule, n in by_rule.most_common(15):
print(f' {n:5} {rule}')
# WHERE THEY ARE, NOT ONLY HOW MANY. The counts above say the backlog moved and
# not one place to go and look, so acting on this job meant opening the Security
# tab and reading 2,234 alerts to find the 144 that ship. Every one of those is
# listed below with its path and line, grouped by rule, so a failing run is
# already the work list. Capped per rule because a hundred lines of the same style
# finding is the log nobody reads that this whole job exists to replace.
print('\nshipped, in full:')
for rule, n in by_rule.most_common():
print(f'\n {rule} ({n})')
shown = 0
for alert in shipped:
if alert['rule']['id'] != rule:
continue
if shown == 12:
print(f' … and {n - shown} more')
break
where = alert.get('most_recent_instance', {}).get('location', {})
print(f' {path_of(alert)}:{where.get("start_line", "?")}')
shown += 1
print('\nshipped, by tree:')
for name, n in Counter(tree(path_of(a)) for a in shipped).most_common():
print(f' {n:5} {name}')
# EXAMINED, NOT IGNORED. Each of these was opened, read, and recorded in
# .github/code-scanning-baseline.json with what taking the advice would have cost —
# a file that does not compile, an allocation, or a deleted justification. Printed
# here so the excuse is visible on every run rather than only in the file, and so a
# count that starts drifting from the recorded reasons is something somebody sees.
print('\nexamined and not gated, by rule:')
for rule, n in Counter(a['rule']['id'] for a in examined).most_common():
print(f' {n:5} {rule}')
print('\ntests, by rule (not gated):')
for rule, n in Counter(a['rule']['id'] for a in tests).most_common():
print(f' {n:5} {rule}')
ceiling = baseline['total']
if not gated:
print(f'\nContext only: {ref} is this pull request\'s base, and the ceiling is '
'enforced when the merge lands on it.')
sys.exit(0)
if ceiling is None:
print('::warning title=Code scanning backlog is not yet gated::'
f'{total} shipped alerts. Record this number as `total` in '
'.github/code-scanning-baseline.json to turn this job into a gate.')
sys.exit(0)
if total > ceiling:
print(f'::error::{total} shipped alerts, above the committed ceiling of {ceiling}.')
sys.exit(1)
print(f'{total} shipped alerts, at or below the ceiling of {ceiling}.')
PY