Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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