From 87a6c6df842279eb77d43605d03a10f0c752b6e1 Mon Sep 17 00:00:00 2001 From: SennayT Date: Fri, 7 Aug 2026 07:45:14 +0000 Subject: [PATCH] add malware scan --- .github/scripts/scan.js | 401 +++++++++++++++++- .github/workflows/deploy.yml | 10 +- .github/workflows/malware-scan.yml | 161 +++++++ .../portal/tailwind.config.js | 2 +- 4 files changed, 560 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/malware-scan.yml diff --git a/.github/scripts/scan.js b/.github/scripts/scan.js index 7b1924b3f..2526dff2b 100644 --- a/.github/scripts/scan.js +++ b/.github/scripts/scan.js @@ -35,23 +35,50 @@ const CONFIG = { "tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs", + "tailwind.config.mjs", "tailwind.js", "postcss.config.js", + "postcss.config.ts", "postcss.config.mjs", "postcss.config.cjs", "babel.config.js", + "babel.config.ts", + "babel.config.mjs", "babel.config.cjs", "next.config.js", + "next.config.ts", "next.config.mjs", "next.config.cjs", + "eslint.config.js", + "eslint.config.ts", + "eslint.config.mjs", + "eslint.config.cjs", "astro.config.mjs", "astro.config.js", "vite.config.js", "vite.config.ts", + "vite.config.mjs", + "vite.config.cjs", "webpack.config.js", "webpack.mix.js", + "svelte.config.js", + "nuxt.config.ts", ], + // Font files — the campaign appends JS payloads to web fonts, which are + // never executed directly but are fetched by the build and used as a + // staging blob. Binary formats, so they are read as latin1. + fontExtensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"], + + // Minimum number of \uXXXX escape sequences in one file before it is + // treated as deliberately obfuscated. Legitimate source files use a + // handful at most; PolinRider samples carry 400–600. + maxLegitUnicodeEscapes: 20, + + // Any single line longer than this inside a config file means code was + // appended past the real export block. + maxLegitConfigLineLength: 1000, + // Files intentionally containing malware indicators for scanner logic/tests. // These filenames are skipped before malware rules are evaluated. ignoredFilenames: ["scan.js"], @@ -187,14 +214,166 @@ const RULES = [ }, }, + { + id: "POLINRIDER-018", + severity: "CRITICAL", + description: + "Unicode-escape string obfuscation — the payload writes plain ASCII literals such as require and https as \\u0068\\u0074\\u0074\\u0070 so that grep, code review, and GitHub diff search cannot see the API calls it makes", + test(content) { + // Only printable-ASCII escapes count. Minified vendor bundles legitimately + // carry hundreds of \uXXXX escapes, but those decode to emoji, diacritics + // and CJK ranges — escaping ASCII that could have been written literally + // has no purpose other than hiding it from a reader. + const asciiEscapes = (content.match(/\\u00[0-7][0-9a-fA-F]/g) || []).filter( + (e) => { + const code = parseInt(e.slice(2), 16); + return code >= 0x20 && code <= 0x7e; + }, + ); + return asciiEscapes.length >= CONFIG.maxLegitUnicodeEscapes + ? [ + `${asciiEscapes.length} printable-ASCII \\uXXXX escapes (threshold: ${CONFIG.maxLegitUnicodeEscapes})`, + ] + : []; + }, + }, + + { + id: "POLINRIDER-019", + severity: "CRITICAL", + description: + "Ethereum dead-drop C2 resolver — the attacker wallet's latest transaction encodes the C2 IP addresses in the 'to' field, so the C2 can be rotated without touching the implant", + test(_content, _filePath, _lines, decoded) { + const iocs = [ + "0xa322e5f3d311d3080e6f0121063e9adc2490ef1a", + "eth.blockscout.com", + "eth_getBlockByNumber", + "eth_getTransactionCount", + "eth_blockNumber", + "ethereum-rpc.publicnode.com", + "eth-mainnet.public.blastapi.io", + "1rpc.io/eth", + "eth.drpc.org", + ]; + const hay = decoded.toLowerCase(); + return iocs.filter((i) => hay.includes(i)); + }, + }, + + { + id: "POLINRIDER-020", + severity: "CRITICAL", + description: + "Remote code execution stager — fetches a payload over HTTP, XOR-decrypts it, eval()s it in-process and re-launches it as a detached hidden node -e child process that survives the build exiting", + test(_content, _filePath, _lines, decoded) { + const hits = []; + if (/spawn\s*\(\s*["']node["']\s*,\s*\[\s*["']-e["']/.test(decoded)) + hits.push('spawn("node", ["-e", ])'); + if (/detached\s*:\s*(!0|true)/.test(decoded)) + hits.push("detached child process"); + if (/stdio\s*:\s*["']ignore["']/.test(decoded)) + hits.push('stdio:"ignore" (output suppressed)'); + if (/\beval\s*\(\s*\w+\s*\+/.test(decoded)) + hits.push("eval() of concatenated remote string"); + if (/x-payload-b64/i.test(decoded)) + hits.push("x-payload-b64 C2 response header"); + // Only report when this is a genuine stager, not an isolated keyword. + return hits.length >= 2 ? hits : []; + }, + }, + + { + id: "POLINRIDER-021", + severity: "CRITICAL", + description: + "Campaign marker + Node internals capture via dot notation — the implant stores its victim/campaign ID and re-exposes require/module on globalThis so later stages can load native modules from inside an ES module", + test(_content, _filePath, _lines, decoded) { + const hits = []; + const marker = decoded.match( + /global\s*\.\s*[a-zA-Z_$]\w*\s*=\s*["']([A-Z]{0,2}\d[\d-]{3,})["']/, + ); + if (marker) hits.push(`campaign marker: "${marker[1]}"`); + if (/global\s*\.\s*\w+\s*=\s*require\b/.test(decoded)) + hits.push("global. = require"); + if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded)) + hits.push("global. = module"); + return hits; + }, + }, + + { + id: "POLINRIDER-022", + severity: "CRITICAL", + description: + "Web font file carrying an executable payload — .woff/.woff2 files are treated as opaque binary assets by reviewers and linters, so the campaign uses them to smuggle JavaScript past code review", + test(content, filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (!CONFIG.fontExtensions.includes(ext)) return []; + + const hits = []; + const magic = content.slice(0, 4); + const expected = { ".woff": "wOFF", ".woff2": "wOF2", ".otf": "OTTO" }; + if (expected[ext] && magic !== expected[ext]) { + hits.push( + `bad magic bytes: expected "${expected[ext]}", got "${magic.replace(/[^\x20-\x7e]/g, ".")}"`, + ); + } + const codeMarkers = [ + "require(", + "eval(", + "child_process", + "global.", + "createRequire", + "process.env", + ]; + const found = codeMarkers.filter((m) => content.includes(m)); + if (found.length > 0) { + hits.push(`embedded JS markers: ${found.join(", ")}`); + } + return hits; + }, + }, + + { + id: "POLINRIDER-023", + severity: "CRITICAL", + description: + ".vscode/tasks.json configured to auto-execute on folder open — gives the campaign code execution the moment a developer opens the repo in VS Code, before any build or install command is run", + test(content, filePath) { + if (!/\.vscode[\\/]tasks\.json$/.test(filePath.replace(/\\/g, "/"))) + return []; + const hits = []; + if (/"runOn"\s*:\s*"folderOpen"/.test(content)) + hits.push('runOn: "folderOpen" (executes without user action)'); + const cmd = content.match(/"command"\s*:\s*"([^"]{0,120})"/); + if (cmd && /node|curl|wget|powershell|bash|-e\b|eval/i.test(cmd[1])) + hits.push(`command: "${cmd[1]}"`); + return hits.length >= 1 ? hits : []; + }, + }, + + { + id: "POLINRIDER-024", + severity: "HIGH", + description: + ".gitignore lists this campaign's persistence artifacts — the implant appends these entries so its own dropped files never appear in git status and the developer never sees them", + test(content, filePath) { + if (path.basename(filePath) !== ".gitignore") return []; + const lines = content.split("\n").map((l) => l.trim()); + return CONFIG.persistenceArtifacts.filter((a) => lines.includes(a)).map( + (a) => `.gitignore hides "${a}"`, + ); + }, + }, + // ── Tier 2: Behavioral / structural indicators ───────────────────────────── { id: "POLINRIDER-009", severity: "HIGH", description: - "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers", - test(content) { + "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers", + test(_content, _filePath, _lines, decoded) { const endpoints = [ "trongrid.io", "aptoslabs.com", @@ -202,7 +381,7 @@ const RULES = [ "bsc-rpc.publicnode.com", "eth_getTransactionByHash", ]; - return endpoints.filter((e) => content.includes(e)); + return endpoints.filter((e) => decoded.includes(e)); }, }, @@ -210,11 +389,10 @@ const RULES = [ id: "POLINRIDER-010", severity: "HIGH", description: - "Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly", - test(content) { - return /windowsHide\s*:\s*true/.test(content) - ? ["windowsHide:true found"] - : []; + "Hidden process spawn with windowsHide — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly. Matches both true and its minified form !0", + test(_content, _filePath, _lines, decoded) { + const m = decoded.match(/windowsHide\s*:\s*(true|!0)/); + return m ? [`windowsHide:${m[1]} found`] : []; }, }, @@ -237,7 +415,10 @@ const RULES = [ severity: "HIGH", description: "Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views", - test(content, _filePath, lines) { + test(_content, filePath, lines) { + // Binary assets contain long runs of 0x20 as padding — not an indicator. + if (CONFIG.fontExtensions.includes(path.extname(filePath).toLowerCase())) + return []; const hits = []; lines.forEach((line, i) => { const spaceRun = line.match(/\s{100,}/); @@ -269,6 +450,27 @@ const RULES = [ }, }, + { + id: "POLINRIDER-025", + severity: "HIGH", + description: + "Minified code appended past the end of a config file — real config files are hand-written and line-wrapped; a single multi-thousand-character line means a payload was concatenated onto the original file", + test(_content, filePath, lines) { + const base = path.basename(filePath).toLowerCase(); + if (!CONFIG.targetedFilenames.some((f) => f.toLowerCase() === base)) + return []; + const hits = []; + lines.forEach((line, i) => { + if (line.length > CONFIG.maxLegitConfigLineLength) { + hits.push( + `line ${i + 1}: ${line.length} chars (threshold: ${CONFIG.maxLegitConfigLineLength})`, + ); + } + }); + return hits; + }, + }, + { id: "POLINRIDER-014", severity: "HIGH", @@ -330,25 +532,58 @@ const RULES = [ // ─── Scanner Engine ──────────────────────────────────────────────────────────── +/** + * Resolve \uXXXX and \xXX escapes so string-matching rules see the real API + * calls. The campaign writes every literal as escapes specifically to defeat + * grep, so rules that match on plain text must run against this form. + * The decoded text is appended to the original rather than replacing it, so a + * rule can still match either representation with one pass. + */ +function deobfuscate(content) { + if (!/\\[ux]/.test(content)) return content; + const decoded = content + .replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => { + try { + return String.fromCodePoint(parseInt(h, 16)); + } catch { + return m; + } + }) + .replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) => + String.fromCharCode(parseInt(h, 16)), + ) + .replace(/\\x([0-9a-fA-F]{2})/g, (_m, h) => + String.fromCharCode(parseInt(h, 16)), + ); + return content + "\n/* --- deobfuscated --- */\n" + decoded; +} + function scanFile(filePath) { if (shouldIgnoreFile(filePath)) { return { filePath, findings: [], skipped: true }; } + // Fonts are binary. latin1 maps bytes 1:1 to chars, so magic-byte checks and + // ASCII payload searches both work without mangling the content. + const isBinary = CONFIG.fontExtensions.includes( + path.extname(filePath).toLowerCase(), + ); + let content; try { - content = fs.readFileSync(filePath, "utf8"); + content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8"); } catch (err) { return { filePath, error: err.message, findings: [] }; } const lines = content.split("\n"); + const decoded = deobfuscate(content); const findings = []; for (const rule of RULES) { let matches; try { - matches = rule.test(content, filePath, lines); + matches = rule.test(content, filePath, lines, decoded); } catch (err) { matches = [`[rule error: ${err.message}]`]; } @@ -388,6 +623,7 @@ function walkDir(dir, results = []) { } else if (entry.isFile() && !shouldIgnoreFile(full)) { const ext = path.extname(entry.name).toLowerCase(); const base = entry.name.toLowerCase(); + const rel = full.replace(/\\/g, "/"); // Scan all JS/TS config files + any file matching a targeted name const isTargetedName = CONFIG.targetedFilenames.some( @@ -399,8 +635,18 @@ function walkDir(dir, results = []) { const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes( ext, ); + const isFont = CONFIG.fontExtensions.includes(ext); + const isGitignore = base === ".gitignore"; + const isVsCodeTask = /\.vscode\/tasks\.json$/.test(rel); - if (isTargetedName || isPersistenceArtifact || isJsLike) { + if ( + isTargetedName || + isPersistenceArtifact || + isJsLike || + isFont || + isGitignore || + isVsCodeTask + ) { results.push(full); } } @@ -532,10 +778,141 @@ function printReport(allResults, { json = false, outputFile = null } = {}) { return infected.length > 0; } +// ─── Self-test ───────────────────────────────────────────────────────────────── + +/** + * Runs the rule set against synthetic samples. Guards the two properties that + * matter: the live payload is still caught, and minified vendor bundles that + * legitimately contain \uXXXX escapes are still not flagged. + * Run with: node scan.js --self-test + */ +function selfTest() { + const os = require("os"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "polinrider-selftest-")); + const write = (name, body) => { + const p = path.join(tmp, name); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + return p; + }; + + const esc = (s) => + [...s].map((c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")).join(""); + + const cases = []; + + // 1. The live payload shape: ASCII-escaped strings + ETH dead drop + stager. + cases.push({ + name: "infected postcss.config.js", + file: write( + "infected/postcss.config.js", + `export default { plugins: {} };` + + " ".repeat(300) + + `global.i="A8-4299";global.r=require;global.m=module;` + + `const http=require("${esc("http")}"),{spawn}=require("${esc("child_process")}");` + + `S="0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a".toLowerCase(),` + + `I="${esc("https://eth.blockscout.com/api")}";` + + `rc(t,"${esc("eth_getBlockByNumber")}");eval(r+o);` + + `spawn("node",["-e",r+o],{detached:!0,stdio:"${esc("ignore")}",windowsHide:!0});`, + ), + expect: true, + }); + + // 2. Clean config — must stay silent. + cases.push({ + name: "clean postcss.config.js", + file: write( + "clean/postcss.config.js", + "export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n", + ), + expect: false, + }); + + // 3. Minified vendor bundle with many non-ASCII escapes — must stay silent. + cases.push({ + name: "minified vendor bundle", + file: write( + "vendor/emoji.min.js", + "var e=[" + + Array.from({ length: 400 }, (_, i) => `"\\ud83d\\ude${(i % 90) + 10}"`).join(",") + + "];", + ), + expect: false, + }); + + // 4. Font carrying an appended JS payload. + cases.push({ + name: "trojanised .woff2", + file: write( + "fonts/bad.woff2", + "wOF2" + "".repeat(64) + 'require("child_process");global.x=1;eval(a+b);', + ), + expect: true, + }); + + // 5. Clean font — correct magic, no code markers. + cases.push({ + name: "clean .woff2", + file: write("fonts/good.woff2", "wOF2" + "".repeat(200)), + expect: false, + }); + + // 6. .gitignore hiding persistence artifacts. + cases.push({ + name: ".gitignore with persistence artifacts", + file: write( + "ignore/.gitignore", + "node_modules/\ntemp_auto_push.bat\nbranch_structure.json\n", + ), + expect: true, + }); + + // 7. VS Code task auto-executing on folder open. + cases.push({ + name: ".vscode/tasks.json auto-exec", + file: write( + "vscode/.vscode/tasks.json", + JSON.stringify({ + version: "2.0.0", + tasks: [ + { + label: "build", + command: "node -e require('http')", + runOptions: { runOn: "folderOpen" }, + }, + ], + }), + ), + expect: true, + }); + + let failed = 0; + for (const c of cases) { + const { findings } = scanFile(c.file); + const detected = findings.length > 0; + const ok = detected === c.expect; + if (!ok) failed++; + const ids = findings.map((f) => f.id).join(", ") || "none"; + console.log( + ` ${ok ? `${ANSI.green}PASS${ANSI.reset}` : `${ANSI.red}FAIL${ANSI.reset}`} ` + + `${c.name} — expected ${c.expect ? "detection" : "clean"}, got: ${ids}`, + ); + } + + fs.rmSync(tmp, { recursive: true, force: true }); + console.log( + failed === 0 + ? `\n${ANSI.green}${ANSI.bold}Self-test passed (${cases.length}/${cases.length}).${ANSI.reset}\n` + : `\n${ANSI.red}${ANSI.bold}Self-test FAILED: ${failed}/${cases.length} case(s).${ANSI.reset}\n`, + ); + process.exit(failed === 0 ? 0 : 1); +} + // ─── CLI Entry Point ─────────────────────────────────────────────────────────── function main() { const args = process.argv.slice(2); + if (args.includes("--self-test")) return selfTest(); const jsonFlag = args.includes("--json"); const outputFileIdx = args.indexOf("--output"); const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bdb4c5ff5..f8a1eb68e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,8 +10,16 @@ permissions: contents: read jobs: + # Supply-chain gate. Every other job depends on this, so a malware detection + # blocks the entire deploy before any build, migration or container starts. + malware-scan: + name: Malware gate + uses: ./.github/workflows/malware-scan.yml + secrets: inherit + detect-changes: name: Detect changed services + needs: malware-scan runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} outputs: matrix: ${{ steps.filter.outputs.matrix }} @@ -90,7 +98,7 @@ jobs: deploy: name: Deploy ${{ matrix.service }} - needs: detect-changes + needs: [malware-scan, detect-changes] if: ${{ needs.detect-changes.outputs.matrix != '[]' }} runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} strategy: diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml new file mode 100644 index 000000000..751118839 --- /dev/null +++ b/.github/workflows/malware-scan.yml @@ -0,0 +1,161 @@ +name: Malware Scan + +# Supply-chain malware gate for the PolinRider / Famous Chollima campaign. +# +# Runs standalone on every push and pull request, and is also called by +# deploy.yml as a required first job — a detection fails this workflow, which +# blocks every downstream deploy job from starting. + +on: + push: + # dev and staging are already gated through deploy.yml's required + # malware-scan job — no need to scan those pushes twice. + branches-ignore: + - dev + - staging + pull_request: + workflow_call: + secrets: + TELEGRAM_BOT_TOKEN: + required: false + TELEGRAM_CHAT_ID: + required: false + +permissions: + contents: read + +# A detection on a ref should not be raced by a newer run of the same ref. +concurrency: + group: malware-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan for PolinRider malware + # Plain `self-hosted` — GitHub applies this label to every self-hosted + # runner automatically. The scan is host-agnostic, unlike the deploy jobs + # which pin to a branch-specific runner. + runs-on: self-hosted + outputs: + infected: ${{ steps.scan.outputs.infected }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify scanner rules still work + # Fails if someone weakens a detection rule or introduces a false + # positive against minified vendor bundles. + run: node .github/scripts/scan.js --self-test + + - name: Scan repository + id: scan + run: | + set -uo pipefail + + # Actions runs this with `bash -e`, so the non-zero exit must be + # caught with `||` rather than read back from $? afterwards. + STATUS=0 + node .github/scripts/scan.js --json --output malware-report.json . || STATUS=$? + + if [ "$STATUS" -eq 0 ]; then + echo "infected=false" >> "$GITHUB_OUTPUT" + echo "No malware detected." + exit 0 + fi + + echo "infected=true" >> "$GITHUB_OUTPUT" + + # Human-readable run for the log, so the failure is legible in the UI. + node .github/scripts/scan.js . || true + exit 1 + + - name: Build alert message + id: message + if: failure() && steps.scan.outputs.infected == 'true' + run: | + set -euo pipefail + + FILES=$(jq -r '.results[].filePath' malware-report.json | head -20) + COUNT=$(jq -r '.infectedFiles' malware-report.json) + RULES=$(jq -r '[.results[].findings[] | select(.severity=="CRITICAL") | .id] | unique | join(", ")' malware-report.json) + + { + echo "message<> "$GITHUB_OUTPUT" + + - name: Notify Telegram + if: failure() && steps.scan.outputs.infected == 'true' + env: + BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + TEXT: ${{ steps.message.outputs.message }} + run: | + set -uo pipefail + + if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then + echo "::warning::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification." + exit 0 + fi + + # No parse_mode: the payload contains characters Telegram's Markdown + # parser would reject, and a failed notification is worse than plain text. + HTTP=$(curl -sS -o /tmp/tg.out -w '%{http_code}' \ + -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${CHAT_ID}" \ + --data-urlencode "text=${TEXT}" \ + --data-urlencode "disable_web_page_preview=true") || true + + if [ "${HTTP:-000}" != "200" ]; then + echo "::warning::Telegram notification failed (HTTP ${HTTP:-000}): $(cat /tmp/tg.out 2>/dev/null | head -c 300)" + else + echo "Telegram alert sent." + fi + rm -f /tmp/tg.out + + - name: Upload scan report + if: always() && hashFiles('malware-report.json') != '' + uses: actions/upload-artifact@v4 + with: + name: malware-report-${{ github.run_id }} + path: malware-report.json + retention-days: 30 + + - name: Job summary + if: always() + run: | + set -uo pipefail + RESULT="${{ steps.scan.outputs.infected }}" + + if [ "$RESULT" = "true" ]; then + { + echo "## 🚨 Malware detected — deployment blocked" + echo "" + echo '```' + jq -r '.results[] | .filePath, (.findings[] | " [\(.id)] \(.severity) — \(.description)")' \ + malware-report.json 2>/dev/null | head -100 || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + elif [ "$RESULT" = "false" ]; then + echo "## ✅ No malware detected" >> "$GITHUB_STEP_SUMMARY" + else + # The scan step never produced a verdict — treat as inconclusive + # rather than clean, so a broken scanner is never read as a pass. + echo "## ⚠️ Scan did not complete — verdict unknown" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index d0cb89d19..1bbfa07ed 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -86,5 +86,5 @@ export default { }, }, plugins: [], -}; +};