Repository navigation
164 lines (158 loc) · 7.85 KB
/
Copy path_security.yml
File metadata and controls
164 lines (158 loc) · 7.85 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
# Reusable: security-static + CodeQL gate.
# Runs independently from lint/test/build — does not block the main pipeline.
# Affects overall workflow success status.
name: Security Gate
on:
workflow_call:
inputs:
lanes:
description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)'
type: string
default: all
permissions:
contents: read
jobs:
security-static:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Layer 1: Static allow-list audit"
run: |
bash tests/test_security_audit_subprocess_calls.sh
scripts/security-audit.sh
- name: "Layer 6: UI security audit"
run: |
bash tests/test_security_ui_urls.sh
scripts/security-ui.sh
- name: "Layer 8: Vendored dependency integrity"
run: scripts/security-vendored.sh
license-gate:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'license-gate') }}
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install ScanCode Toolkit
run: |
CBM_CI_VENV="$RUNNER_TEMP/cbm-ci-venv" scripts/ci/install-python-tools.sh
echo "$RUNNER_TEMP/cbm-ci-venv/bin" >> "$GITHUB_PATH"
- name: "Gate self-test (a planted violation must be detected)"
run: scripts/license-gate.sh --selftest
- name: "License compliance gate (one finding fails)"
run: scripts/license-gate.sh
- name: "License provenance audit (byte-identity vs upstream)"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: scripts/audit-license-provenance.py
codeql-gate:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }}
runs-on: ubuntu-latest
timeout-minutes: 240
# Without security-events:read this job inherits the workflow's
# contents:read, the code-scanning alert API answers 403, and the gate
# below reported "0 alerts" on every run it has ever made. actions:read is
# what the CodeQL-run poll above needs for the same reason.
permissions:
contents: read
security-events: read
actions: read
steps:
- name: Wait for CodeQL on current commit (max 150 min)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# On pull_request events github.sha is the synthetic merge commit;
# CodeQL runs are recorded against the PR head SHA.
CURRENT_SHA="${{ github.event.pull_request.head.sha || github.sha }}"
echo "Waiting for CodeQL to complete on $CURRENT_SHA..."
# 300 attempts x 30s = 150 min. The previous budget was 90 x 30s = 45 min,
# which is shorter than CodeQL actually takes on this repository: a measured
# run on PR #1426 (head 7b72652a) completed with conclusion=success after
# 124 min, 7 minutes AFTER this gate had already given up. That marked at
# least five contributor PRs red for a scan that passed. The job's own
# timeout-minutes is 240, so 150 still leaves headroom.
for attempt in $(seq 1 300); do
LATEST=$(gh api "repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?head_sha=$CURRENT_SHA&per_page=1" \
--jq '.workflow_runs[] | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
if [ -z "$LATEST" ]; then
echo " $attempt/300: no run yet..."; sleep 30; continue
fi
CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1)
STATUS=$(echo "$LATEST" | cut -d' ' -f2)
if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then
echo "=== CodeQL passed ==="; exit 0
elif [ "$STATUS" = "completed" ]; then
echo "BLOCKED: CodeQL $CONCLUSION"; exit 1
fi
echo " $attempt/300: $STATUS..."; sleep 30
done
echo "BLOCKED: CodeQL timeout"; exit 1
- name: Check for open code scanning alerts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A PR is judged by its own CodeQL analysis (refs/pull/N/merge);
# without a ref the API answers for the default branch, so a fix in
# the PR could never clear the gate before it merged.
ALERT_REF: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/merge', github.event.pull_request.number) || '' }}
run: |
# FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`,
# which looks defensive and is the opposite. gh writes the API error
# BODY to stdout, so a 403 made ALERTS the string
# {"message":"Resource not accessible by integration",...,"status":"403"}0
# every `[` comparison then failed as non-integer, the `if` took its
# false branch, and the step printed "CodeQL gate passed (0 alerts)"
# and exited 0. That is exactly how a security gate should NEVER fail:
# silently, in the direction of "everything is fine". An unreadable
# alert list is now an error, not a zero.
# NOTE: this runs in a command substitution, so it must `return`, not
# `exit` -- an `exit` here would only end the subshell and let the
# caller continue with an empty count, recreating the very bug this
# replaces. The callers below check the status explicitly.
# Scope: CodeQL alerts only, outside vendored third-party code.
# Scorecard findings are repository-hygiene signals reported by their
# own workflow, and alerts in vendored grammars/libraries are
# upstream code this repo does not patch, so neither blocks a merge.
# --paginate: the API returns 30 alerts per page by default, so an
# unpaginated count silently stopped at 30.
ALERTS_URL='repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100'
[ -n "$ALERT_REF" ] && ALERTS_URL="$ALERTS_URL&ref=$ALERT_REF"
GATE_FILTER='.[] | select(.tool.name == "CodeQL")
| select((.most_recent_instance.location.path // "")
| test("^(internal/cbm/)?vendored/") | not)'
count_open_alerts() {
local body status
body=$(gh api --paginate \
"$ALERTS_URL" \
--jq "$GATE_FILTER | .number" 2>/dev/null)
status=$?
if [ $status -ne 0 ]; then
echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2
echo " Refusing to treat an unreadable alert list as zero." >&2
echo " Most likely cause: the job lacks security-events:read." >&2
return 1
fi
local count
count=$(printf '%s' "$body" | grep -c . || true)
case "$count" in
''|*[!0-9]*)
echo "BLOCKED: alert count was not a number: ${count:-<empty>}" >&2
return 1
;;
esac
printf '%s' "$count"
}
echo "Waiting 60s for alert API to settle..."
sleep 60
ALERTS=$(count_open_alerts) || exit 1
sleep 15
ALERTS2=$(count_open_alerts) || exit 1
[ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2
if [ "$ALERTS" -gt 0 ]; then
echo "BLOCKED: $ALERTS open alert(s)"
gh api --paginate \
"$ALERTS_URL" \
--jq "$GATE_FILTER"' | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
exit 1
fi
echo "=== CodeQL gate passed (0 gating CodeQL alerts) ==="