Skip to content
286 linesCodeBlameRaw

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
Merge branch 'worktree-agent-a3abfcce648e87dca'162# The job's token reads the code and uploads the results.
163permissions:
164 contents: read
165 security-events: write
166
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar167jobs:
168 scan:
169 name: Code scanning
170 runs-on: ubuntu-latest
171 timeout-minutes: 30
172 env:
173 G1T_TOKEN: \${{ secrets.G1T_TOKEN }}
174 steps:
175 - uses: actions/checkout@v4
176
177 - name: Find the languages to scan
178 id: languages
179 run: |
180 found() { [ -n "$(git ls-files -- "$@" | head -n 1)" ]; }
181 if found '*.py'; then echo "python=true" >> "$GITHUB_OUTPUT"; fi
182 if found 'go.mod' '*/go.mod'; then echo "go=true" >> "$GITHUB_OUTPUT"; fi
183 if found '*.js' '*.jsx' '*.mjs' '*.cjs' '*.ts' '*.tsx' '*.mts' '*.cts'; then echo "javascript=true" >> "$GITHUB_OUTPUT"; fi
184 if found 'Cargo.toml' '*/Cargo.toml'; then echo "rust=true" >> "$GITHUB_OUTPUT"; fi
185 mkdir -p /tmp/sarif
186 # Uploads one SARIF file: upload-sarif <file> <category> [<directory its paths are relative to>]
187 cat > /tmp/upload-sarif <<'SCRIPT'
188 #!/bin/sh
189 set -eu
190 file="$1"; category="$2"; dir="\${3:-.}"
191 if [ "$dir" != "." ]; then
192 jq --arg prefix "$dir/" '(.runs[]?.results[]?.locations[]?.physicalLocation.artifactLocation
193 | select(.uri != null and (.uri | test("^(/|[A-Za-z][A-Za-z0-9+.-]*:)") | not)) | .uri) |= $prefix + .' \\
194 "$file" > "$file.tmp" && mv "$file.tmp" "$file"
195 fi
196 ref="$GITHUB_REF"
197 sha="$GITHUB_SHA"
198 if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
199 ref="refs/pull/$(jq -r .number "$GITHUB_EVENT_PATH")/head"
200 sha="$(jq -r '.pull_request.head.sha // env.GITHUB_SHA' "$GITHUB_EVENT_PATH")"
201 fi
202 gzip -c "$file" | base64 -w0 > "$file.b64"
203 jq -n --arg sha "$sha" --arg ref "$ref" --arg checkout "file://$GITHUB_WORKSPACE" --arg category "$category" --rawfile sarif "$file.b64" \\
204 '{commit_sha: $sha, ref: $ref, sarif: $sarif, checkout_uri: $checkout, category: $category}' > "$file.json"
205 curl --fail-with-body -sS -X POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/code-scanning/sarifs" \\
206 -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" --data @"$file.json"
207 echo
208 SCRIPT
209 chmod +x /tmp/upload-sarif
210
211 - name: Python (Bandit)
212 if: steps.languages.outputs.python == 'true'
213 run: |
214 python3 -m venv /tmp/bandit && /tmp/bandit/bin/pip install --quiet "bandit[sarif]"
215 /tmp/bandit/bin/bandit --recursive . --exclude ./.git,./node_modules,./.venv,./venv \\
216 --format sarif --output /tmp/sarif/python.sarif --exit-zero --quiet
217 /tmp/upload-sarif /tmp/sarif/python.sarif python
218
219 - name: Go (gosec)
220 if: steps.languages.outputs.go == 'true'
221 run: |
222 go install github.com/securego/gosec/v2/cmd/gosec@latest
223 gosec="$(go env GOPATH)/bin/gosec"
224 # Each module on its own, its results under its own category.
225 for dir in $(git ls-files -- 'go.mod' '*/go.mod' | xargs -n1 dirname); do
226 out="/tmp/sarif/go-$(echo "$dir" | tr '/.' '__').sarif"
227 (cd "$dir" && "$gosec" -quiet -no-fail -fmt sarif -out "$out" ./...)
228 if [ "$dir" = "." ]; then category="go"; else category="go:$dir"; fi
229 /tmp/upload-sarif "$out" "$category" "$dir"
230 done
231
232 - name: JavaScript and TypeScript (ESLint)
233 if: steps.languages.outputs.javascript == 'true'
234 run: |
235 mkdir -p /tmp/eslint
236 npm install --prefix /tmp/eslint --no-audit --no-fund --silent \\
237 eslint@9 eslint-plugin-security typescript-eslint typescript @microsoft/eslint-formatter-sarif
238 cat > /tmp/eslint/eslint.config.mjs <<'CONFIG'
239 import security from "eslint-plugin-security";
240 import tseslint from "typescript-eslint";
241
242 const files = ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"];
243
244 export default [
245 { ignores: ["**/node_modules/", "**/dist/", "**/build/", "**/coverage/", "**/vendor/", "**/*.min.js"] },
246 { files, languageOptions: { parser: tseslint.parser, parserOptions: { ecmaFeatures: { jsx: true } } } },
247 { ...security.configs.recommended, files },
248 ];
249 CONFIG
250 formatter="$(node -p "require.resolve('@microsoft/eslint-formatter-sarif', { paths: ['/tmp/eslint'] })")"
251 # 1 is findings; 2 is ESLint failing to run.
252 /tmp/eslint/node_modules/.bin/eslint --config /tmp/eslint/eslint.config.mjs --no-warn-ignored \\
253 --format "$formatter" --output-file /tmp/sarif/javascript.sarif . || [ $? -eq 1 ]
254 /tmp/upload-sarif /tmp/sarif/javascript.sarif javascript
255
256 - name: Rust (Clippy)
257 if: steps.languages.outputs.rust == 'true'
258 run: |
259 cargo install --locked --quiet clippy-sarif
260 # The workspace at the top, or else each outermost crate.
261 if [ -f Cargo.toml ]; then
262 roots="."
263 else
264 roots="$(git ls-files -- '*/Cargo.toml' | xargs -n1 dirname | sort | awk 'NR == 1 || index($0 "/", last "/") != 1 { print; last = $0 }')"
265 fi
266 for dir in $roots; do
267 out="/tmp/sarif/rust-$(echo "$dir" | tr '/.' '__').sarif"
268 (cd "$dir" && cargo clippy --all-targets --message-format=json > /tmp/clippy.json) || true
269 clippy-sarif < /tmp/clippy.json > "$out"
270 if [ "$dir" = "." ]; then category="rust"; else category="rust:$dir"; fi
271 /tmp/upload-sarif "$out" "$category" "$dir"
272 done
273`;
274}
275
276/** The pull request that adds it. */
277export function codeScanningPullBody(defaultBranch: string): string {
278 return [
279 `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.`,
280 "",
281 `- On \`${defaultBranch}\`, each result opens a code scanning alert on the Security page; one no longer reported is fixed.`,
282 "- 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.",
283 "",
284 "Change the rules, or add another tool that writes SARIF with its own category, in the workflow. Merge this to start.",
285 ].join("\n");
286}

This file's history is long; its oldest lines are credited to the oldest commit read.