mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Add malware and obfuscation scanning workflow
This commit is contained in:
256
.github/workflows/malware-scan.yaml
vendored
Normal file
256
.github/workflows/malware-scan.yaml
vendored
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
name: Malware & Obfuscation Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
# Allow manual triggering for ad-hoc full scans
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
scan_path:
|
||||||
|
description: "Sub-directory to scan (leave blank for full repo)"
|
||||||
|
required: false
|
||||||
|
default: "."
|
||||||
|
|
||||||
|
# Prevent concurrent scans on the same ref from stepping on each other
|
||||||
|
concurrency:
|
||||||
|
group: malware-scan-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
# Needed if you later add GitHub Code Scanning / SARIF upload
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
malware-scan:
|
||||||
|
name: Scan for malicious / obfuscated code
|
||||||
|
runs-on: self-hosted
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# ── 1. Checkout ────────────────────────────────────────────────────────
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history lets the scanner see every file, not just the diff.
|
||||||
|
# For very large repos you can set fetch-depth: 1 to speed things up,
|
||||||
|
# but you may miss injected files in unchanged paths.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# ── 2. Setup Node ──────────────────────────────────────────────────────
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
# ── 3. Install scanner ─────────────────────────────────────────────────
|
||||||
|
# The scanner is pure Node.js stdlib — no npm install needed.
|
||||||
|
# We just copy the script into a known location inside the runner.
|
||||||
|
- name: Install scanner script
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TOOL_CACHE/malware-scanner"
|
||||||
|
cp .github/scripts/scan-malware.js \
|
||||||
|
"$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js"
|
||||||
|
chmod +x "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js"
|
||||||
|
|
||||||
|
# ── 4. Run the scanner ─────────────────────────────────────────────────
|
||||||
|
- name: Run malware scanner
|
||||||
|
id: scan
|
||||||
|
env:
|
||||||
|
SCAN_JSON_OUT: ${{ runner.temp }}/scan-results.json
|
||||||
|
run: |
|
||||||
|
SCAN_PATH="${{ github.event.inputs.scan_path || '.' }}"
|
||||||
|
echo "Scanning path: $SCAN_PATH"
|
||||||
|
echo "────────────────────────────────────────"
|
||||||
|
|
||||||
|
node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" "$SCAN_PATH"
|
||||||
|
# The script exits 0 (clean), 1 (threats found), or 2 (internal error).
|
||||||
|
# We want the step to "succeed" so the upload-artifact step always runs,
|
||||||
|
# but we'll fail the job in the gate step below.
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# ── 5. Upload JSON report as artifact (always, even on failure) ────────
|
||||||
|
# Retained so the full per-file, per-rule detail is always downloadable.
|
||||||
|
# The Telegram message below links directly to the Actions run where
|
||||||
|
# this artifact appears.
|
||||||
|
- name: Upload scan report artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: malware-scan-report-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/scan-results.json
|
||||||
|
retention-days: 90
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
# ── 6. Check whether any critical findings were reported ────────────────
|
||||||
|
- name: Check for critical findings
|
||||||
|
id: critical
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
SCAN_JSON: ${{ runner.temp }}/scan-results.json
|
||||||
|
run: |
|
||||||
|
node - << 'EOF'
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
let findings = [];
|
||||||
|
try {
|
||||||
|
findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8'));
|
||||||
|
} catch { /* missing file = clean run or scanner error */ }
|
||||||
|
|
||||||
|
const criticalCount = findings.filter(f => f.severity === 'CRITICAL').length;
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `count=${criticalCount}\n`);
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `has_critical=${criticalCount > 0}\n`);
|
||||||
|
console.log(`Critical findings: ${criticalCount}`);
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── 7. Send Telegram notification for critical findings only ─────────────
|
||||||
|
# Requires two repository secrets:
|
||||||
|
# TELEGRAM_BOT_TOKEN — from @BotFather (format: 123456:ABC-xxx)
|
||||||
|
# TELEGRAM_CHAT_ID — target chat/channel ID (format: -100xxxxxxxxxx)
|
||||||
|
#
|
||||||
|
# Intentionally short — plain HTML mode, no code spans, no snippets.
|
||||||
|
# All special characters that would break MarkdownV2 are avoided entirely.
|
||||||
|
# Full details are in the artifact linked via the Actions run URL.
|
||||||
|
- name: Send Telegram notification
|
||||||
|
if: always() && steps.critical.outputs.has_critical == 'true'
|
||||||
|
env:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
SCAN_JSON: ${{ runner.temp }}/scan-results.json
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
GH_SHA: ${{ github.sha }}
|
||||||
|
GH_REF: ${{ github.ref_name }}
|
||||||
|
GH_ACTOR: ${{ github.actor }}
|
||||||
|
GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
node - << 'EOF'
|
||||||
|
const fs = require('fs');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
const chatId = process.env.TELEGRAM_CHAT_ID;
|
||||||
|
const repo = process.env.GH_REPO;
|
||||||
|
const sha = process.env.GH_SHA.slice(0, 7);
|
||||||
|
const ref = process.env.GH_REF;
|
||||||
|
const actor = process.env.GH_ACTOR;
|
||||||
|
const runUrl = process.env.GH_RUN_URL;
|
||||||
|
|
||||||
|
// HTML-escape only the four characters HTML cares about.
|
||||||
|
// Using HTML parse_mode means code snippets, file paths, and rule IDs
|
||||||
|
// with special characters can never break the parser.
|
||||||
|
const h = s => String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
|
||||||
|
let findings = [];
|
||||||
|
try {
|
||||||
|
findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8'));
|
||||||
|
} catch { /* missing file = clean run or scanner error */ }
|
||||||
|
|
||||||
|
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
||||||
|
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
||||||
|
|
||||||
|
const criticalFindings = findings.filter(f => f.severity === 'CRITICAL');
|
||||||
|
|
||||||
|
// ── Unique affected files ──────────────────────────────────────────
|
||||||
|
const affectedFiles = [...new Set(criticalFindings.map(f => f.file))];
|
||||||
|
|
||||||
|
// ── Build a short, fixed-size message ─────────────────────────────
|
||||||
|
// No snippets, no descriptions — just counts, affected files, and a
|
||||||
|
// direct link to the artifact. Stays well under 500 chars.
|
||||||
|
let lines = [];
|
||||||
|
|
||||||
|
lines.push('🚨 <b>Malware Scan — CRITICAL THREATS DETECTED</b>');
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`<b>Repo:</b> ${h(repo)}`);
|
||||||
|
lines.push(`<b>Branch:</b> ${h(ref)} <b>Commit:</b> ${h(sha)}`);
|
||||||
|
lines.push(`<b>Actor:</b> ${h(actor)}`);
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push(
|
||||||
|
`<b>Findings:</b> ` +
|
||||||
|
`🔴 ${counts.CRITICAL} CRITICAL ` +
|
||||||
|
`🟠 ${counts.HIGH} HIGH ` +
|
||||||
|
`🟡 ${counts.MEDIUM} MEDIUM ` +
|
||||||
|
`⚪ ${counts.LOW} LOW`
|
||||||
|
);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`<b>Critical affected files (${affectedFiles.length}):</b>`);
|
||||||
|
// Cap at 10 files to keep the message short
|
||||||
|
const shown = affectedFiles.slice(0, 10);
|
||||||
|
for (const f of shown) lines.push(` • ${h(f)}`);
|
||||||
|
if (affectedFiles.length > 10) {
|
||||||
|
lines.push(` • … and ${affectedFiles.length - 10} more`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`📋 <a href="${h(runUrl)}">View full run & download report artifact</a>`);
|
||||||
|
|
||||||
|
const text = lines.join('\n');
|
||||||
|
|
||||||
|
// ── Send via Bot API (HTML parse mode) ────────────────────────────
|
||||||
|
const body = JSON.stringify({
|
||||||
|
chat_id: chatId,
|
||||||
|
text,
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: 'api.telegram.org',
|
||||||
|
path: `/bot${token}/sendMessage`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
console.error('Telegram API error:', JSON.stringify(parsed));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Telegram notification sent successfully.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', err => {
|
||||||
|
console.error('Request failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── 8. Gate — fail the workflow if critical findings were found ─────────
|
||||||
|
# Runs after the Telegram step so the alert always fires first.
|
||||||
|
- name: Fail workflow if critical threats detected
|
||||||
|
if: always() && steps.critical.outputs.has_critical == 'true'
|
||||||
|
run: |
|
||||||
|
echo "::error::⛔ Critical malicious or highly-suspicious code patterns were detected."
|
||||||
|
echo "::error::Check your Telegram channel for the summary."
|
||||||
|
echo "::error::Download the 'malware-scan-report' artifact for full details."
|
||||||
|
echo "::error::Do NOT merge or deploy this branch until findings are reviewed."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# ── 9. (Optional) Diff-only scan on PRs for faster feedback ───────────
|
||||||
|
# Uncomment this block if you want a second, faster pass that only
|
||||||
|
# checks the files changed in the PR diff.
|
||||||
|
#
|
||||||
|
# - name: Diff-only scan (PR only)
|
||||||
|
# if: github.event_name == 'pull_request'
|
||||||
|
# env:
|
||||||
|
# SCAN_JSON_OUT: ${{ runner.temp }}/scan-results-diff.json
|
||||||
|
# run: |
|
||||||
|
# git diff --name-only origin/${{ github.base_ref }}...HEAD \
|
||||||
|
# | grep -E '\.(js|cjs|mjs|ts|tsx|jsx)$' \
|
||||||
|
# | xargs -I{} node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" {}
|
||||||
Reference in New Issue
Block a user