Skip to content

g1t/apps/web/app/lib/security-suite.ts

281 lines13,486 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1/**
2 * What the security pages work out from what the security service gives
3 * them: filters read from the address, the starter code scanning workflow,
4 * where old Security links go now, and trends scaled for drawing. Pure, so
5 * it is tested on its own (security-suite.test.ts).
6 */
7import type {
8 AlertState,
9 CodeAlert,
10 SecretFinding,
11 Severity,
12 SeverityCounts,
13 TrendPoint,
14 Vulnerability,
15} from "@g1t/contracts";
16
17const STATES: AlertState[] = ["open", "dismissed", "fixed"];
18const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "unknown"];
19
20function oneOf<T extends string>(value: string | null, allowed: readonly T[]): T | null {
21 return value && (allowed as readonly string[]).includes(value) ? (value as T) : null;
22}
23
24/** Secret scanning's filters, from the page's address. */
25export type SecretFilters = {
26 state: AlertState;
27 type: string | null;
28 validity: "active" | "inactive" | "unknown" | "unsupported" | null;
29 bypassed: boolean | null;
30};
31
32export function secretFilters(search: URLSearchParams): SecretFilters {
33 const bypassed = search.get("bypassed");
34 return {
35 state: oneOf(search.get("state"), STATES) ?? "open",
36 type: search.get("type") || null,
37 validity: oneOf(search.get("validity"), ["active", "inactive", "unknown", "unsupported"] as const),
38 bypassed: bypassed === "true" ? true : bypassed === "false" ? false : null,
39 };
40}
41
42export function keepSecret(secret: SecretFinding, filters: SecretFilters): boolean {
43 return (
44 secret.state === filters.state &&
45 (!filters.type || secret.kind === filters.type) &&
46 (!filters.validity || (secret.validity ?? "unknown") === filters.validity) &&
47 (filters.bypassed == null || Boolean(secret.bypass) === filters.bypassed)
48 );
49}
50
51/** The kinds of secret a list holds, for its filter, as (id, label). */
52export function secretTypes(secrets: SecretFinding[]): [string, string][] {
53 const seen = new Map<string, string>();
54 for (const secret of secrets) {
55 const label = secret.kind === "custom_pattern" ? `Custom: ${secret.patternName ?? "pattern"}` : secret.label.replace(/^an? /, "");
56 if (!seen.has(secret.kind)) seen.set(secret.kind, label.charAt(0).toUpperCase() + label.slice(1));
57 }
58 return [...seen.entries()].sort((a, b) => a[1].localeCompare(b[1]));
59}
60
61/** Code scanning's filters. */
62export type CodeFilters = { state: AlertState; severity: Severity | null; tool: string | null };
63
64export function codeFilters(search: URLSearchParams): CodeFilters {
65 return {
66 state: oneOf(search.get("state"), STATES) ?? "open",
67 severity: oneOf(search.get("severity"), SEVERITIES),
68 tool: search.get("tool") || null,
69 };
70}
71
72export function keepCode(alert: CodeAlert, filters: CodeFilters): boolean {
73 return (
74 alert.state === filters.state &&
75 (!filters.severity || alert.severity === filters.severity) &&
76 (!filters.tool || alert.tool === filters.tool)
77 );
78}
79
80/** How many alerts of each state, for the filter's counts. */
81export function countStates<T extends { state: AlertState }>(alerts: T[]): Record<AlertState, number> {
82 const counts: Record<AlertState, number> = { open: 0, dismissed: 0, fixed: 0 };
83 for (const alert of alerts) counts[alert.state] += 1;
84 return counts;
85}
86
87/** Open alerts by severity. */
88export function severityCounts(items: { severity: Severity; state: AlertState }[]): SeverityCounts {
89 const counts: SeverityCounts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
90 for (const item of items) if (item.state === "open") counts[item.severity] += 1;
91 return counts;
92}
93
94export function total(counts: SeverityCounts): number {
95 return counts.critical + counts.high + counts.medium + counts.low + counts.unknown;
96}
97
98/**
99 * Where an old Security link goes now: `?tab=secrets&finding=sec_…` (git's
100 * push refusals sent these) and `?tab=dependencies`. Null when it is the
101 * overview's own address.
102 */
103export function legacySecurityTarget(base: string, search: URLSearchParams): string | null {
104 const finding = search.get("finding");
105 const tab = search.get("tab");
106 if (finding?.startsWith("sec_")) return `${base}/security/secret-scanning/${finding}`;
107 if (finding?.startsWith("vul_")) return `${base}/security/vulnerabilities?finding=${finding}`;
108 if (tab === "secrets") return `${base}/security/secret-scanning${search.get("state") ? `?state=${search.get("state")}` : ""}`;
109 if (tab === "dependencies") return `${base}/security/vulnerabilities${search.get("state") ? `?state=${search.get("state")}` : ""}`;
110 return null;
111}
112
113/** A trend's points scaled to the tallest day, for drawing bars. */
114export function trendMax(points: TrendPoint[]): number {
115 return Math.max(1, ...points.map((point) => point.secretScanning + point.codeScanning + point.vulnerability));
116}
117
118/** Whether a vulnerability is open and at least `severity`. */
119export function atLeast(severity: Severity, threshold: Severity): boolean {
120 return SEVERITIES.indexOf(severity) <= SEVERITIES.indexOf(threshold);
121}
122
123/** Open vulnerabilities by package, worst first: for the overview's list. */
124export function worstVulnerabilities(vulns: Vulnerability[], limit = 5): Vulnerability[] {
125 return vulns
126 .filter((vuln) => vuln.state === "open")
127 .sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || a.package.localeCompare(b.package))
128 .slice(0, limit);
129}
130
131/** The branch "Set up code scanning" commits on: the first free name. */
132export function codeScanningBranch(taken: string[]): string {
133 const names = new Set(taken);
134 if (!names.has("add-code-scanning")) return "add-code-scanning";
135 for (let n = 2; ; n += 1) if (!names.has(`add-code-scanning-${n}`)) return `add-code-scanning-${n}`;
136}
137
138/**
139 * The starter code scanning workflow: a scanner for each language the
140 * repository has (Bandit for Python, gosec for Go, ESLint with
141 * eslint-plugin-security for JavaScript and TypeScript, Clippy for Rust),
142 * on every pull request, every push to the default branch and weekly. Each
143 * language's SARIF is uploaded with its own category, with the job's own
144 * token. Each scanner installs from its language's registry, which the
145 * runner's egress allows.
146 */
147export function codeScanningWorkflow(defaultBranch: string): string {
148 return `# Code scanning: a scanner for each language the repository has, with each
149# one's results uploaded to g1t as SARIF under its own category. Alerts open
150# on ${defaultBranch}; on a pull request, new results on the lines it changes become
151# review comments and the Code scanning check.
152# https://docs.g1t.sh/guides/security/code-scanning/
153name: Code scanning
154
155on:
156 push:
157 branches: [${JSON.stringify(defaultBranch)}]
158 pull_request:
159 schedule:
160 - cron: "27 4 * * 1"
161
162jobs:
163 scan:
164 name: Code scanning
165 runs-on: ubuntu-latest
166 timeout-minutes: 30
167 env:
168 G1T_TOKEN: \${{ secrets.G1T_TOKEN }}
169 steps:
170 - uses: actions/checkout@v4
171
172 - name: Find the languages to scan
173 id: languages
174 run: |
175 found() { [ -n "$(git ls-files -- "$@" | head -n 1)" ]; }
176 if found '*.py'; then echo "python=true" >> "$GITHUB_OUTPUT"; fi
177 if found 'go.mod' '*/go.mod'; then echo "go=true" >> "$GITHUB_OUTPUT"; fi
178 if found '*.js' '*.jsx' '*.mjs' '*.cjs' '*.ts' '*.tsx' '*.mts' '*.cts'; then echo "javascript=true" >> "$GITHUB_OUTPUT"; fi
179 if found 'Cargo.toml' '*/Cargo.toml'; then echo "rust=true" >> "$GITHUB_OUTPUT"; fi
180 mkdir -p /tmp/sarif
181 # Uploads one SARIF file: upload-sarif <file> <category> [<directory its paths are relative to>]
182 cat > /tmp/upload-sarif <<'SCRIPT'
183 #!/bin/sh
184 set -eu
185 file="$1"; category="$2"; dir="\${3:-.}"
186 if [ "$dir" != "." ]; then
187 jq --arg prefix "$dir/" '(.runs[]?.results[]?.locations[]?.physicalLocation.artifactLocation
188 | select(.uri != null and (.uri | test("^(/|[A-Za-z][A-Za-z0-9+.-]*:)") | not)) | .uri) |= $prefix + .' \\
189 "$file" > "$file.tmp" && mv "$file.tmp" "$file"
190 fi
191 ref="$GITHUB_REF"
192 sha="$GITHUB_SHA"
193 if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
194 ref="refs/pull/$(jq -r .number "$GITHUB_EVENT_PATH")/head"
195 sha="$(jq -r '.pull_request.head.sha // env.GITHUB_SHA' "$GITHUB_EVENT_PATH")"
196 fi
197 gzip -c "$file" | base64 -w0 > "$file.b64"
198 jq -n --arg sha "$sha" --arg ref "$ref" --arg checkout "file://$GITHUB_WORKSPACE" --arg category "$category" --rawfile sarif "$file.b64" \\
199 '{commit_sha: $sha, ref: $ref, sarif: $sarif, checkout_uri: $checkout, category: $category}' > "$file.json"
200 curl --fail-with-body -sS -X POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/code-scanning/sarifs" \\
201 -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" --data @"$file.json"
202 echo
203 SCRIPT
204 chmod +x /tmp/upload-sarif
205
206 - name: Python (Bandit)
207 if: steps.languages.outputs.python == 'true'
208 run: |
209 python3 -m venv /tmp/bandit && /tmp/bandit/bin/pip install --quiet "bandit[sarif]"
210 /tmp/bandit/bin/bandit --recursive . --exclude ./.git,./node_modules,./.venv,./venv \\
211 --format sarif --output /tmp/sarif/python.sarif --exit-zero --quiet
212 /tmp/upload-sarif /tmp/sarif/python.sarif python
213
214 - name: Go (gosec)
215 if: steps.languages.outputs.go == 'true'
216 run: |
217 go install github.com/securego/gosec/v2/cmd/gosec@latest
218 gosec="$(go env GOPATH)/bin/gosec"
219 # Each module on its own, its results under its own category.
220 for dir in $(git ls-files -- 'go.mod' '*/go.mod' | xargs -n1 dirname); do
221 out="/tmp/sarif/go-$(echo "$dir" | tr '/.' '__').sarif"
222 (cd "$dir" && "$gosec" -quiet -no-fail -fmt sarif -out "$out" ./...)
223 if [ "$dir" = "." ]; then category="go"; else category="go:$dir"; fi
224 /tmp/upload-sarif "$out" "$category" "$dir"
225 done
226
227 - name: JavaScript and TypeScript (ESLint)
228 if: steps.languages.outputs.javascript == 'true'
229 run: |
230 mkdir -p /tmp/eslint
231 npm install --prefix /tmp/eslint --no-audit --no-fund --silent \\
232 eslint@9 eslint-plugin-security typescript-eslint typescript @microsoft/eslint-formatter-sarif
233 cat > /tmp/eslint/eslint.config.mjs <<'CONFIG'
234 import security from "eslint-plugin-security";
235 import tseslint from "typescript-eslint";
236
237 const files = ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"];
238
239 export default [
240 { ignores: ["**/node_modules/", "**/dist/", "**/build/", "**/coverage/", "**/vendor/", "**/*.min.js"] },
241 { files, languageOptions: { parser: tseslint.parser, parserOptions: { ecmaFeatures: { jsx: true } } } },
242 { ...security.configs.recommended, files },
243 ];
244 CONFIG
245 formatter="$(node -p "require.resolve('@microsoft/eslint-formatter-sarif', { paths: ['/tmp/eslint'] })")"
246 # 1 is findings; 2 is ESLint failing to run.
247 /tmp/eslint/node_modules/.bin/eslint --config /tmp/eslint/eslint.config.mjs --no-warn-ignored \\
248 --format "$formatter" --output-file /tmp/sarif/javascript.sarif . || [ $? -eq 1 ]
249 /tmp/upload-sarif /tmp/sarif/javascript.sarif javascript
250
251 - name: Rust (Clippy)
252 if: steps.languages.outputs.rust == 'true'
253 run: |
254 cargo install --locked --quiet clippy-sarif
255 # The workspace at the top, or else each outermost crate.
256 if [ -f Cargo.toml ]; then
257 roots="."
258 else
259 roots="$(git ls-files -- '*/Cargo.toml' | xargs -n1 dirname | sort | awk 'NR == 1 || index($0 "/", last "/") != 1 { print; last = $0 }')"
260 fi
261 for dir in $roots; do
262 out="/tmp/sarif/rust-$(echo "$dir" | tr '/.' '__').sarif"
263 (cd "$dir" && cargo clippy --all-targets --message-format=json > /tmp/clippy.json) || true
264 clippy-sarif < /tmp/clippy.json > "$out"
265 if [ "$dir" = "." ]; then category="rust"; else category="rust:$dir"; fi
266 /tmp/upload-sarif "$out" "$category" "$dir"
267 done
268`;
269}
270
271/** The pull request that adds it. */
272export function codeScanningPullBody(defaultBranch: string): string {
273 return [
274 `This adds \`.g1t/workflows/code-scanning.yml\`, which scans each language the repository has, on every pull request, every push to \`${defaultBranch}\` and weekly: [Bandit](https://bandit.readthedocs.io) for Python, [gosec](https://securego.io) for Go, [ESLint](https://eslint.org) with [eslint-plugin-security](https://www.npmjs.com/package/eslint-plugin-security) for JavaScript and TypeScript, and [Clippy](https://doc.rust-lang.org/clippy/) for Rust. Each language's results are uploaded to g1t as SARIF under their own category.`,
275 "",
276 `- On \`${defaultBranch}\`, each result opens a code scanning alert on the Security page; one no longer reported is fixed.`,
277 "- On a pull request, results new to it on the lines it changes are left as review comments, and the **Code scanning** check fails at the threshold set in the repository's Security settings. Require that check in branch protection to block merges on it.",
278 "",
279 "Change the rules, or add another tool that writes SARIF with its own category, in the workflow. Merge this to start.",
280 ].join("\n");
281}