Skip to main content

gitlab-security-setup

Sets up a full security stack on your company's projects hosted on GitLab.com (non-PrestaShop: Laravel, Astro, TanStack, etc.). Use ONLY when the project is a GitLab.com Free tier project. Triggers when the user asks to add dependency scanning, vulnerability alerts, security setup, Trivy, pnpm supply chain protection, or wants email reports of vulnerabilities. Do NOT use for GitHub-hosted projects, personal projects, or PrestaShop projects — use ps-security-audit skill instead for any PrestaShop project.

Source facts

Repository
educlopez/mallard
Last source activity
July 27, 2026 at 06:49
Detected SKILL.md language
English
Stars
4
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
gitlab-security-setup
description
Sets up a full security stack on your company's projects hosted on GitLab.com (non-PrestaShop: Laravel, Astro, TanStack, etc.). Use ONLY when the project is a GitLab.com Free tier project. Triggers when the user asks to add dependency scanning, vulnerability alerts, security setup, Trivy, pnpm supply chain protection, or wants email reports of vulnerabilities. Do NOT use for GitHub-hosted projects, personal projects, or PrestaShop projects — use ps-security-audit skill instead for any PrestaShop project.
version
0.2.1
metadata
{"author":"Eduardo Calvo"}
# GitLab Security Setup Full security stack for your company's GitLab.com projects on the Free tier. Covers: pnpm 11 supply chain, Trivy weekly scan, HTML email reports via Gmail. > **Placeholder:** `{report_recipients}` is a comma-separated list of email > addresses that receive the vulnerability reports (e.g. `you@example.com, teammate@example.com`). > Replace it everywhere it appears below with your own recipient address(es) before running. ## What gets set up 1. **pnpm 11** with supply chain protection (`minimumReleaseAge`, overrides) 2. **Trivy** vulnerability + secret scanner via GitLab CI 3. **Weekly scheduled pipeline** (Monday 8am Madrid) with HTML email report 4. **Composer audit** for PHP/Laravel projects 5. **Gmail SMTP** delivery via GitLab CI/CD variables --- ## Step 1 — pnpm 11 Supply Chain ### `pnpm-workspace.yaml` (create or update) ```yaml # WARNING: single-package repos do NOT need a `packages:` block on pnpm 11. # BUT on pnpm 9 (Vercel default for older projects) the mere presence of this # file REQUIRES a non-empty `packages:` or install dies with # "packages field missing or empty". If targeting pnpm 9, add `packages: ['.']`. minimumReleaseAge: 4320 # packages must be 72h old before install (minutes) # Block transitive deps from git repos / raw tarball URLs (needs pnpm 10.26+, # silently inert below). See supply-chain-security skill for the full checklist. blockExoticSubdeps: true # Allowlist for postinstall/build scripts. pnpm 10+ blocks ALL by default. # List ONLY packages that genuinely need to compile. pnpm will prompt you to # add new entries when you install a dep with a blocked build script. allowBuilds: esbuild: true sharp: true # lightningcss-cli: true # uncomment if using lightningcss overrides: form-data: ">=4.0.6" axios: ">=1.15.2" lodash: ">=4.18.0" picomatch: ">=4.0.4" qs: ">=6.14.2" shell-quote: ">=1.8.4" ``` **Rules:** - `minimumReleaseAge` is in **minutes** (4320 = 72h). Blocks supply chain attacks via typosquatting/fast-publish. - `blockExoticSubdeps` needs pnpm 10.26+ — silently inert below. Check the pinned version. - `allowBuilds` — pnpm 10+ blocks all postinstall scripts by default. Add ONLY packages that need to compile. pnpm 11 will tell you during install if a new dep needs adding here. - `overrides` pins known vulnerable transitive deps. Add new entries as CVEs appear. - Do NOT put `minimumReleaseAge` in `.npmrc` — pnpm 11 reads it from `pnpm-workspace.yaml` only. - **Real test is CI**: `pnpm install --frozen-lockfile` on a clean machine enforces all policies; a warm local cache skips them. ### `package.json` additions ```json { "packageManager": "pnpm@11.1.2", "private": true } ``` Pin the **exact** version (not `11.x.x`) so CI/Vercel use the version you tested. Remove any `overrides` or `pnpm.overrides` blocks from `package.json` — they belong in `pnpm-workspace.yaml` for pnpm 11. ### `publicar` deploy script (if project has one) ```bash #!/bin/bash php artisan migrate --force pnpm build ``` Ensure it uses `pnpm`, not `npm run`. ### GitHub Actions lint workflow (if exists) Replace `npm ci` / `npm install` / `npm run` with: ```yaml - run: npm install -g pnpm - run: pnpm install --frozen-lockfile - run: pnpm run format - run: pnpm run lint ``` --- ## Step 2 — GitLab CI Trivy Scan Create or update `.gitlab-ci.yml`: ```yaml dependency-scan: image: name: aquasec/trivy:latest entrypoint: [""] before_script: - apk add --no-cache curl python3 py3-packaging script: # JSON report (structured data) — vuln + secret scan - trivy fs --exit-code 0 --scanners vuln,secret --format json -o trivy-report.json . 2>/dev/null # HTML report artifact - trivy fs --exit-code 0 --scanners vuln,secret --format template --template "@/contrib/html.tpl" -o trivy-report.html . 2>/dev/null || true # Parse JSON and build HTML email - | python3 << 'PYEOF' import json, os with open("trivy-report.json") as f: data = json.load(f) from packaging.version import Version, InvalidVersion def highest_fix(fixed_str): if not fixed_str or fixed_str == "N/A": return None parts = [p.strip() for p in fixed_str.split(",") if p.strip()] parsed = [] for p in parts: try: parsed.append((Version(p), p)) except InvalidVersion: parsed.append((Version("0"), p)) return max(parsed, key=lambda x: x[0])[1] if parsed else None grouped = {} severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4} for result in data.get("Results", []): for v in result.get("Vulnerabilities", []): key = (v.get("PkgName", ""), v.get("InstalledVersion", "")) fixed_raw = v.get("FixedVersion", "") sev = v.get("Severity", "UNKNOWN") cve = v.get("VulnerabilityID", "") fix = highest_fix(fixed_raw) if key not in grouped: grouped[key] = {"pkg": key[0], "installed": key[1], "severity": sev, "cves": [], "fixes": []} entry = grouped[key] if severity_order.get(sev, 5) < severity_order.get(entry["severity"], 5): entry["severity"] = sev if cve: entry["cves"].append(cve) if fix: entry["fixes"].append(fix) def max_version(versions): parsed = [] for v in versions: try: parsed.append((Version(v), v)) except InvalidVersion: pass return max(parsed, key=lambda x: x[0])[1] if parsed else None vulns = list(grouped.values()) for entry in vulns: entry["best_fix"] = max_version(entry["fixes"]) vulns.sort(key=lambda x: severity_order.get(x["severity"], 5)) counts = {s: sum(1 for v in vulns if v["severity"] == s) for s in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]} total = len(vulns) sev_colors = { "CRITICAL": ("#fff1f2", "#be123c", "#fecdd3"), "HIGH": ("#fffbeb", "#b45309", "#fde68a"), "MEDIUM": ("#eff6ff", "#1d4ed8", "#bfdbfe"), "LOW": ("#f9fafb", "#374151", "#e5e7eb"), } project = os.environ.get("CI_PROJECT_NAME", "") branch = os.environ.get("CI_COMMIT_REF_NAME", "") sha = os.environ.get("CI_COMMIT_SHORT_SHA", "") pipeline_url = os.environ.get("CI_PIPELINE_URL", "#") gmail_user = os.environ.get("GMAIL_USER", "") # Comma-separated recipient list — set REPORT_RECIPIENTS as a CI/CD variable report_recipients = os.environ.get("REPORT_RECIPIENTS", "") pipeline_created_at = os.environ.get("CI_PIPELINE_CREATED_AT", "") scan_date = pipeline_created_at[:10] if pipeline_created_at else "" scan_ts = pipeline_created_at.replace("T", " ").replace("Z", " UTC") if pipeline_created_at else "" def cve_links(cves, limit=5): links = [] for cve in cves[:limit]: if cve.startswith("CVE-"): links.append(f"<a href='https://nvd.nist.gov/vuln/detail/{cve}' style='color:#f97316;text-decoration:none'>{cve}</a>") else: links.append(cve) out = ", ".join(links) if len(cves) > limit: out += f"&nbsp;<span style='color:#98A2B3'>+{len(cves)-limit} more</span>" return out sev_pills = { "CRITICAL": ("background:#FFF1F3;color:#C01048;border:1px solid #FFC5D0", "C"), "HIGH": ("background:#FFFAEB;color:#B54708;border:1px solid #FEDF89", "H"), "MEDIUM": ("background:#EFF8FF;color:#175CD3;border:1px solid #B2DDFF", "M"), "LOW": ("background:#F9FAFB;color:#344054;border:1px solid #D0D5DD", "L"), } dot = {"CRITICAL": "#F04438", "HIGH": "#F79009", "MEDIUM": "#2E90FA", "LOW": "#98A2B3"} rows = "" for i, v in enumerate(vulns): pill_style, _ = sev_pills.get(v["severity"], ("background:#F9FAFB;color:#344054;border:1px solid #D0D5DD", "?")) fixed = v["best_fix"] if v["best_fix"] else "<span style='color:#98A2B3'>No fix yet</span>" cves_html = cve_links(v["cves"]) sep = "border-bottom:1px solid #EAECF0;" if i < len(vulns) - 1 else "" rows += f"""<tr style='background:#ffffff'> <td style='padding:8px 16px;{sep}font-size:13px;color:#101828;font-weight:500;white-space:nowrap'>{v['pkg']}</td> <td style='padding:8px 16px;{sep}font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#475467'>{v['installed']}</td> <td style='padding:8px 16px;{sep}font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#101828;font-weight:500'>{fixed}</td> <td style='padding:8px 16px;{sep}white-space:nowrap'> <span style='{pill_style};padding:2px 7px;border-radius:9999px;font-size:11px;font-weight:500;display:inline-block'>{v['severity'].capitalize()}</span> </td> <td style='padding:8px 16px;{sep}font-size:12px;color:#475467'>{cves_html}</td> </tr>""" badges = "".join([ f"<span style='display:inline-block;padding:3px 10px;border-radius:9999px;font-size:12px;font-weight:500;margin-right:6px;{sev_pills[s][0]}'>{s.capitalize()} {counts[s]}</span>" for s in ["CRITICAL", "HIGH", "MEDIUM", "LOW"] if counts[s] > 0 ]) table_section = "" if not vulns else f""" <table border='0' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='border-collapse:separate;border-spacing:0;border:1px solid #EAECF0;border-radius:8px;overflow:hidden;margin-top:20px'> <thead> <tr style='background:#F9FAFB'> <th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>Package</th> <th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>Installed</th> <th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>Fix to</th> <th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>Severity</th> <th style='padding:10px 16px;text-align:left;font-size:11px;font-weight:500;color:#475467;border-bottom:1px solid #EAECF0'>CVE</th> </tr> </thead> <tbody>{rows}</tbody> </table>""" no_vulns = """ <div style='text-align:center;padding:48px 0'> <p style='font-size:18px;font-weight:600;color:#111827;margin:0 0 8px'>All clear!</p>
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub