diff --git a/docs/gitlab-ci.md b/docs/gitlab-ci.md new file mode 100644 index 00000000..006a40d3 --- /dev/null +++ b/docs/gitlab-ci.md @@ -0,0 +1,248 @@ +# GitLab CI/CD Integration + +Run ISNAD Scanner automatically on every merge request and push to your default branch. Findings appear in GitLab's Security Dashboard. + +## Quick Start + +Copy the template into your repo: + +```bash +cp templates/gitlab-ci.yml .gitlab-ci.yml +git add .gitlab-ci.yml +git commit -m "ci: add isnad-scanner security scan" +git push +``` + +That's it. The scanner runs on every pipeline and uploads results to GitLab's Security Dashboard. + +--- + +## How It Works + +``` +push / MR ──▶ install @isnad/scanner ──▶ batch scan ──▶ JSON results + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + GitLab report SARIF report Raw JSON + (gl-sast-report) (isnad-sarif) (isnad-results) + │ + ▼ + GitLab Security Dashboard +``` + +1. **Install** — pulls `@isnad/scanner` from npm (cached between runs) +2. **Scan** — runs `npx @isnad/scanner batch` against your target glob pattern +3. **Convert** — transforms JSON findings to GitLab Security Report format +4. **Report** — uploads the report artifact so findings appear in the Security Dashboard +5. **Gate** — optionally fails the pipeline on critical/high findings + +--- + +## CI/CD Variables + +Configure these in **Settings → CI/CD → Variables** or directly in `.gitlab-ci.yml`. + +| Variable | Default | Description | +|---|---|---| +| `ISNAD_TARGET` | `**/*.{js,ts,py,mjs,cjs}` | Glob pattern for files to scan | +| `ISNAD_OUTPUT_FORMAT` | `gitlab` | Output format: `gitlab` (Security Dashboard), `sarif`, or `json` | +| `ISNAD_FAIL_ON_RISK` | `true` | Fail pipeline on critical/high findings | +| `ISNAD_FAIL_FAST` | `false` | Stop after first critical/high finding | +| `ISNAD_VERSION` | `latest` | Scanner version to install (`latest` or semver) | +| `ISNAD_NODE_IMAGE` | `node:20-alpine` | Docker image for the job | + +--- + +## Example Configurations + +### Minimal + +Scan everything with defaults: + +```yaml +include: + - remote: 'https://raw.githubusercontent.com/counterspec/isnad/main/templates/gitlab-ci.yml' +``` + +Or copy the template as-is — zero configuration needed. + +### Custom Target + +Scan only your `skills/` directory: + +```yaml +variables: + ISNAD_TARGET: "skills/**/*.{js,ts,py}" +``` + +### Non-Blocking (Audit Mode) + +Run the scan but never fail the pipeline: + +```yaml +variables: + ISNAD_FAIL_ON_RISK: "false" +``` + +Findings still appear in the Security Dashboard — you just don't gate on them. + +### Fail Fast + +Stop immediately when a critical/high issue is found (useful for large repos): + +```yaml +variables: + ISNAD_FAIL_FAST: "true" + ISNAD_FAIL_ON_RISK: "true" +``` + +### SARIF Output + +Generate SARIF 2.1.0 output for external tools (written to `isnad-sarif.json`): + +```yaml +variables: + ISNAD_OUTPUT_FORMAT: "sarif" +``` + +> **Note:** SARIF output is saved as a regular artifact, not as a GitLab SAST report. +> Only the `gitlab` format integrates with the Security Dashboard. + +### JSON Output Only + +Skip conversion, produce raw scanner JSON (available as `isnad-results.json`): + +```yaml +variables: + ISNAD_OUTPUT_FORMAT: "json" +``` + +### Pinned Version + +Lock the scanner version for reproducible builds: + +```yaml +variables: + ISNAD_VERSION: "0.1.0" +``` + +### Monorepo + +Scan multiple directories with separate jobs: + +```yaml +include: + - remote: 'https://raw.githubusercontent.com/counterspec/isnad/main/templates/gitlab-ci.yml' + +# Override for the backend +isnad-scan-backend: + extends: isnad-scan + variables: + ISNAD_TARGET: "backend/skills/**/*.ts" + +# Override for the frontend +isnad-scan-frontend: + extends: isnad-scan + variables: + ISNAD_TARGET: "frontend/plugins/**/*.{js,ts}" + +# Override for Python agents +isnad-scan-agents: + extends: isnad-scan + variables: + ISNAD_TARGET: "agents/**/*.py" +``` + +### Full Configuration + +Every knob turned: + +```yaml +variables: + ISNAD_TARGET: "src/**/*.{js,ts,mjs}" + ISNAD_OUTPUT_FORMAT: "gitlab" + ISNAD_FAIL_ON_RISK: "true" + ISNAD_FAIL_FAST: "false" + ISNAD_VERSION: "0.1.0" + ISNAD_NODE_IMAGE: "node:20-alpine" + +include: + - remote: 'https://raw.githubusercontent.com/counterspec/isnad/main/templates/gitlab-ci.yml' +``` + +--- + +## Security Dashboard + +When `ISNAD_OUTPUT_FORMAT` is set to `gitlab` (the default), findings are uploaded as a [SAST artifact](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportssast). This makes them available in: + +- **Merge Request → Security tab** — see new findings introduced by the MR +- **Security → Vulnerability Report** — all findings across the project +- **Pipeline → Security tab** — findings for that specific run + +### Severity Mapping + +| ISNAD Severity | GitLab Severity | +|---|---| +| Critical | Critical | +| High | High | +| Medium | Medium | +| Low | Low | +| Clean | Info | + +--- + +## Using as a Local Dependency + +If `@isnad/scanner` is already in your `package.json`, the CI template detects it automatically and runs `npm ci` instead of a global install. This gives you version locking through your lockfile: + +```bash +npm install --save-dev @isnad/scanner +``` + +--- + +## Caching + +The template caches: + +- **npm packages** — `$CI_PROJECT_DIR/.npm-cache/` +- **node_modules** — reused between runs on the same branch + +Cache key is derived from `package-lock.json` so it invalidates when dependencies change. + +--- + +## Troubleshooting + +### "Cannot find module '@isnad/scanner'" + +The install might have failed. Check: +- Network connectivity to npm registry +- `ISNAD_VERSION` is a valid published version +- Try `ISNAD_NODE_IMAGE: "node:20"` (non-alpine) if native deps are needed + +### No findings in Security Dashboard + +- Ensure `ISNAD_OUTPUT_FORMAT` is `gitlab` (the default) +- Check that `gl-sast-report.json` appears in the job artifacts +- GitLab Security Dashboard requires **Ultimate** tier for full features (merge request widget works on all tiers) +- Verify the report artifact is not empty + +### Pipeline fails unexpectedly + +If the scanner exit code is non-zero but you want an audit-only mode: +```yaml +variables: + ISNAD_FAIL_ON_RISK: "false" +``` + +### Scanning takes too long + +Narrow the target glob or enable fail-fast: +```yaml +variables: + ISNAD_TARGET: "src/skills/**/*.ts" + ISNAD_FAIL_FAST: "true" +``` diff --git a/templates/gitlab-ci.yml b/templates/gitlab-ci.yml new file mode 100644 index 00000000..37e03c45 --- /dev/null +++ b/templates/gitlab-ci.yml @@ -0,0 +1,282 @@ +# ============================================================================ +# ISNAD Scanner — GitLab CI/CD Integration Template +# ============================================================================ +# +# Drop this into your project as .gitlab-ci.yml (or include it remotely) +# to scan AI agent skills/resources for malicious patterns. +# +# Quick start: +# 1. Copy this file to .gitlab-ci.yml in your repo root +# 2. Optionally set ISNAD_TARGET in CI/CD variables +# 3. Push — scans run automatically on every pipeline +# +# Docs: docs/gitlab-ci.md +# ============================================================================ + +variables: + # ── Scan targets ────────────────────────────────────────────────────────── + # Glob pattern for files to scan. Defaults to all JS/TS/Python files. + ISNAD_TARGET: "**/*.{js,ts,py,mjs,cjs}" + + # ── Output ──────────────────────────────────────────────────────────────── + # Output format for GitLab Security Dashboard: + # "gitlab" — GitLab Security Report format (default, shows in Security Dashboard) + # "sarif" — SARIF 2.1.0 (for external tools) + # "json" — Raw isnad-scanner JSON + ISNAD_OUTPUT_FORMAT: "gitlab" + + # ── Behavior ────────────────────────────────────────────────────────────── + # Fail the pipeline on critical/high findings (true/false) + ISNAD_FAIL_ON_RISK: "true" + # Stop scanning after the first critical/high finding + ISNAD_FAIL_FAST: "false" + + # ── Scanner version ────────────────────────────────────────────────────── + # Pin a version or use "latest" + ISNAD_VERSION: "latest" + + # ── Node / Docker ───────────────────────────────────────────────────────── + ISNAD_NODE_IMAGE: "node:20-alpine" + +stages: + - test + - security + +# ============================================================================ +# Stage: security — ISNAD Scan +# ============================================================================ +isnad-scan: + stage: security + image: ${ISNAD_NODE_IMAGE} + variables: + NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm-cache" + + cache: + key: + prefix: isnad-scanner + files: + - package-lock.json + paths: + - .npm-cache/ + - node_modules/ + policy: pull-push + + before_script: + # ── Install isnad-scanner ─────────────────────────────────────────────── + # If the project already lists @isnad/scanner as a dependency, use it. + # Otherwise install via npm for npx to resolve. + - | + if [ -f "package.json" ] && grep -q "@isnad/scanner" package.json 2>/dev/null; then + echo "▸ Installing project dependencies (scanner is a local dep)..." + npm ci --prefer-offline --no-audit + else + echo "▸ Installing @isnad/scanner..." + if [ "$ISNAD_VERSION" = "latest" ]; then + npm install -g @isnad/scanner + else + npm install -g "@isnad/scanner@${ISNAD_VERSION}" + fi + fi + + script: + - echo "╔══════════════════════════════════════════╗" + - echo "║ ISNAD Scanner — GitLab CI ║" + - echo "╚══════════════════════════════════════════╝" + - echo "" + - echo "Target pattern → ${ISNAD_TARGET}" + - echo "Output format → ${ISNAD_OUTPUT_FORMAT}" + - echo "Fail on risk → ${ISNAD_FAIL_ON_RISK}" + - echo "Fail fast → ${ISNAD_FAIL_FAST}" + - echo "" + + # ── Run the scan ────────────────────────────────────────────────────── + - | + SCAN_CMD="npx @isnad/scanner batch" + SCAN_ARGS="" + if [ "$ISNAD_FAIL_FAST" = "true" ]; then + SCAN_ARGS="$SCAN_ARGS --fail-fast" + fi + + # Always produce JSON first (base for all conversions) + set +e + $SCAN_CMD "${ISNAD_TARGET}" --json $SCAN_ARGS > isnad-results.json 2>&1 + SCAN_EXIT=$? + set -e + + echo "Scanner exit code: $SCAN_EXIT" + echo "" + + # ── Convert output ──────────────────────────────────────────────────── + - | + if [ "$ISNAD_OUTPUT_FORMAT" = "gitlab" ] || [ "$ISNAD_OUTPUT_FORMAT" = "sarif" ]; then + node -e " + const fs = require('fs'); + const crypto = require('crypto'); + + const raw = fs.readFileSync('isnad-results.json', 'utf8'); + const results = JSON.parse(raw); + const arr = Array.isArray(results) ? results : [results]; + + const FORMAT = process.env.ISNAD_OUTPUT_FORMAT || 'gitlab'; + const startTime = new Date().toISOString(); + + if (FORMAT === 'gitlab') { + // ── GitLab Security Report format ────────────────────────── + const vulnerabilities = []; + for (const entry of arr) { + const file = entry.file || 'unknown'; + const res = entry.result || entry; + for (const f of (res.findings || [])) { + const idSrc = file + ':' + f.patternId + ':' + f.line; + const hash = crypto.createHash('sha256').update(idSrc).digest('hex'); + const uuid = [ + hash.slice(0, 8), hash.slice(8, 12), hash.slice(12, 16), + hash.slice(16, 20), hash.slice(20, 32) + ].join('-'); + + const severity = f.severity.charAt(0).toUpperCase() + f.severity.slice(1); + + vulnerabilities.push({ + id: uuid, + category: 'sast', + name: f.name, + message: f.description, + description: f.description + ' (matched: ' + f.match + ')', + severity: severity, + confidence: 'High', + scanner: { id: 'isnad-scanner', name: 'ISNAD Scanner' }, + location: { + file: file, + start_line: f.line, + end_line: f.line + }, + identifiers: [{ + type: 'isnad_pattern', + name: f.name, + value: f.patternId + }] + }); + } + } + + const report = { + version: '15.1.0', + vulnerabilities: vulnerabilities, + scan: { + scanner: { + id: 'isnad-scanner', + name: 'ISNAD Scanner', + version: '0.1.0', + vendor: { name: 'ISNAD Protocol' } + }, + type: 'sast', + status: 'success', + start_time: startTime, + end_time: new Date().toISOString() + } + }; + fs.writeFileSync('gl-sast-report.json', JSON.stringify(report, null, 2)); + console.log('GitLab SAST report: ' + vulnerabilities.length + ' vulnerabilities across ' + arr.length + ' files'); + + } else if (FORMAT === 'sarif') { + // ── SARIF 2.1.0 ──────────────────────────────────────────── + const sarif = { + '\$schema': 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json', + version: '2.1.0', + runs: [{ + tool: { + driver: { + name: 'isnad-scanner', + informationUri: 'https://isnad.md', + version: '0.1.0', + rules: [] + } + }, + results: [] + }] + }; + const rulesMap = new Map(); + const run = sarif.runs[0]; + for (const entry of arr) { + const file = entry.file || 'unknown'; + const res = entry.result || entry; + for (const f of (res.findings || [])) { + if (!rulesMap.has(f.patternId)) { + rulesMap.set(f.patternId, { + id: f.patternId, + name: f.name, + shortDescription: { text: f.name }, + fullDescription: { text: f.description }, + defaultConfiguration: { + level: f.severity === 'critical' || f.severity === 'high' ? 'error' : + f.severity === 'medium' ? 'warning' : 'note' + }, + properties: { severity: f.severity, category: f.category } + }); + } + run.results.push({ + ruleId: f.patternId, + level: f.severity === 'critical' || f.severity === 'high' ? 'error' : + f.severity === 'medium' ? 'warning' : 'note', + message: { text: f.description + ' (matched: ' + f.match + ')' }, + locations: [{ + physicalLocation: { + artifactLocation: { uri: file }, + region: { + startLine: f.line, + startColumn: f.column, + snippet: { text: f.context } + } + } + }] + }); + } + } + run.tool.driver.rules = [...rulesMap.values()]; + // Write SARIF to its own file (not gl-sast-report.json — SARIF is not + // a valid GitLab Security Report and would fail the SAST artifact upload) + fs.writeFileSync('isnad-sarif.json', JSON.stringify(sarif, null, 2)); + console.log('SARIF report: ' + run.results.length + ' findings across ' + arr.length + ' files'); + } + " + elif [ "$ISNAD_OUTPUT_FORMAT" = "json" ]; then + echo "Raw JSON output available at isnad-results.json" + fi + + # ── Summary ─────────────────────────────────────────────────────────── + - | + echo "" + echo "── Scan complete ──────────────────────────────────────────" + if [ -f gl-sast-report.json ]; then + echo "Report saved to gl-sast-report.json" + fi + + # ── Enforce policy ──────────────────────────────────────────────────── + - | + if [ "$ISNAD_FAIL_ON_RISK" = "true" ] && [ "$SCAN_EXIT" -ne 0 ]; then + echo "" + echo "⚠ Critical or high-risk findings detected. Failing pipeline." + exit 1 + fi + + artifacts: + when: always + paths: + - isnad-results.json + - gl-sast-report.json + - isnad-sarif.json + reports: + # Only populated when ISNAD_OUTPUT_FORMAT=gitlab (default). + # SARIF and JSON formats produce separate files and skip this report. + sast: + - gl-sast-report.json + expire_in: 30 days + + rules: + # Run on merge requests + - if: $CI_MERGE_REQUEST_IID + # Run on default branch pushes + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + # Allow manual trigger from any branch + - when: manual + allow_failure: true