| 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 | */ |
| 7 | import type { |
| 8 | AlertState, |
| 9 | CodeAlert, |
| 10 | SecretFinding, |
| 11 | Severity, |
| 12 | SeverityCounts, |
| 13 | TrendPoint, |
| 14 | Vulnerability, |
| 15 | } from "@g1t/contracts"; |
| 16 | |
| 17 | const STATES: AlertState[] = ["open", "dismissed", "fixed"]; |
| 18 | const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "unknown"]; |
| 19 | |
| 20 | function 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. */ |
| 25 | export type SecretFilters = { |
| 26 | state: AlertState; |
| 27 | type: string | null; |
| 28 | validity: "active" | "inactive" | "unknown" | "unsupported" | null; |
| 29 | bypassed: boolean | null; |
| 30 | }; |
| 31 | |
| 32 | export 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 | |
| 42 | export 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). */ |
| 52 | export 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. */ |
| 62 | export type CodeFilters = { state: AlertState; severity: Severity | null; tool: string | null }; |
| 63 | |
| 64 | export 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 | |
| 72 | export 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. */ |
| 81 | export 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. */ |
| 88 | export 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 | |
| 94 | export 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 | */ |
| 103 | export 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. */ |
| 114 | export 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`. */ |
| 119 | export 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. */ |
| 124 | export 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. */ |
| 132 | export 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 | */ |
| 147 | export 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/ |
| 153 | name: Code scanning |
| 154 | |
| 155 | on: |
| 156 | push: |
| 157 | branches: [${JSON.stringify(defaultBranch)}] |
| 158 | pull_request: |
| 159 | schedule: |
| 160 | - cron: "27 4 * * 1" |
| 161 | |
| 162 | jobs: |
| 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. */ |
| 272 | export 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 | } |