diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..5f2b66e3 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,21 @@ +stages: + - security + +isnad-scan: + image: node:20 + stage: security + variables: + ISNAD_SCAN_TARGETS: "**/*.{js,ts,mjs,cjs}" + before_script: + - cd scanner + - npm ci + - npm run build + script: + - npm run scan -- gitlab-sast "$ISNAD_SCAN_TARGETS" --output "gl-sast-report.json" + artifacts: + when: always + reports: + sast: scanner/gl-sast-report.json + paths: + - scanner/gl-sast-report.json + allow_failure: true diff --git a/docs/GITLAB_CI.md b/docs/GITLAB_CI.md new file mode 100644 index 00000000..0ec982b5 --- /dev/null +++ b/docs/GITLAB_CI.md @@ -0,0 +1,43 @@ +# GitLab CI Integration + +ISNAD Scanner can run in GitLab CI and publish a SAST report artifact for the GitLab Security Dashboard. + +## Setup + +1. Copy the root `.gitlab-ci.yml` template into a GitLab project. +2. Adjust `ISNAD_SCAN_TARGETS` to the files you want to scan. +3. Run a pipeline and open the Security tab to review findings from `scanner/gl-sast-report.json`. + +## Template + +```yaml +stages: + - security + +isnad-scan: + image: node:20 + stage: security + variables: + ISNAD_SCAN_TARGETS: "**/*.{js,ts,mjs,cjs}" + before_script: + - cd scanner + - npm ci + - npm run build + script: + - npm run scan -- gitlab-sast "$ISNAD_SCAN_TARGETS" --output "gl-sast-report.json" + artifacts: + when: always + reports: + sast: scanner/gl-sast-report.json + paths: + - scanner/gl-sast-report.json + allow_failure: true +``` + +## Configuration + +`ISNAD_SCAN_TARGETS` is a glob passed to `isnad-scanner gitlab-sast`. Keep it narrow for faster pipelines, for example `src/**/*.{js,ts}`. + +The template writes the report inside `scanner/` after `cd scanner`, then publishes `scanner/gl-sast-report.json` as a CI artifact. + +Use `--fail-on-findings` in the script if a project wants high or critical scanner findings to fail the pipeline. diff --git a/scanner/package.json b/scanner/package.json index a71b58fe..6f6d2a12 100644 --- a/scanner/package.json +++ b/scanner/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "tsc", + "test": "npm run build && node --test dist/gitlab.test.js", "start": "node dist/index.js", "dev": "tsx watch src/index.ts", "scan": "tsx src/cli.ts" diff --git a/scanner/src/cli.ts b/scanner/src/cli.ts index 5f516738..a577a991 100644 --- a/scanner/src/cli.ts +++ b/scanner/src/cli.ts @@ -4,10 +4,11 @@ */ import { Command } from 'commander'; -import { readFileSync, existsSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import chalk from 'chalk'; import { analyzeContent, formatResult, type AnalysisResult } from './analyzer.js'; +import { toGitLabSastReport } from './gitlab.js'; import { submitFlag, checkBalance, createEvidencePackage, hashEvidence, type OracleConfig } from './oracle.js'; import { createHash } from 'crypto'; import 'dotenv/config'; @@ -239,4 +240,37 @@ program } }); +// GitLab SAST command +program + .command('gitlab-sast') + .description('Scan files and write a GitLab SAST report') + .argument('', 'Glob pattern for files to scan') + .option('-o, --output ', 'Output report path', 'gl-sast-report.json') + .option('--fail-on-findings', 'Exit with code 1 when high or critical findings are present') + .action(async (pattern: string, options) => { + const { glob } = await import('glob'); + const files = await glob(pattern, { nodir: true }); + const results: { file: string; result: AnalysisResult }[] = []; + let hasHighRisk = false; + + for (const file of files) { + const content = readFileSync(file, 'utf-8'); + const resourceHash = `0x${createHash('sha256').update(content).digest('hex')}`; + const result = analyzeContent(content, resourceHash); + results.push({ file, result }); + if (result.riskLevel === 'critical' || result.riskLevel === 'high') { + hasHighRisk = true; + } + } + + const report = toGitLabSastReport(results); + writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); + console.log(chalk.green(`Wrote GitLab SAST report to ${options.output}`)); + console.log(chalk.blue(`Scanned ${files.length} files; ${report.vulnerabilities.length} findings reported`)); + + if (options.failOnFindings && hasHighRisk) { + process.exit(1); + } + }); + program.parse(); diff --git a/scanner/src/gitlab.test.ts b/scanner/src/gitlab.test.ts new file mode 100644 index 00000000..15c680f2 --- /dev/null +++ b/scanner/src/gitlab.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { analyzeContent } from './analyzer.js'; +import { toGitLabSastReport } from './gitlab.js'; + +test('converts scanner findings into GitLab SAST vulnerabilities', () => { + const result = analyzeContent(` + const { exec } = require("child_process"); + exec("curl https://example.invalid"); + `); + const report = toGitLabSastReport([{ file: 'fixtures/malicious.js', result }]); + + assert.equal(report.version, '15.0.0'); + assert.equal(report.scan.type, 'sast'); + assert.ok(report.vulnerabilities.length >= 1); + assert.equal(report.vulnerabilities[0].category, 'sast'); + assert.equal(report.vulnerabilities[0].location.file, 'fixtures/malicious.js'); + assert.ok(report.vulnerabilities[0].identifiers[0].value.startsWith('EXEC_')); +}); + +test('emits an empty GitLab SAST report when no findings are present', () => { + const result = analyzeContent('const safe = JSON.stringify({ ok: true });'); + const report = toGitLabSastReport([{ file: 'fixtures/safe.js', result }]); + + assert.equal(report.vulnerabilities.length, 0); + assert.equal(report.scan.status, 'success'); +}); diff --git a/scanner/src/gitlab.ts b/scanner/src/gitlab.ts new file mode 100644 index 00000000..cdf1c212 --- /dev/null +++ b/scanner/src/gitlab.ts @@ -0,0 +1,127 @@ +import { createHash } from 'crypto'; +import type { AnalysisResult, Finding } from './analyzer.js'; + +interface GitLabIdentifier { + type: string; + name: string; + value: string; +} + +interface GitLabVulnerability { + id: string; + category: 'sast'; + name: string; + message: string; + description: string; + severity: 'Critical' | 'High' | 'Medium' | 'Low' | 'Info'; + confidence: 'High' | 'Medium' | 'Low'; + scanner: { + id: string; + name: string; + }; + location: { + file: string; + start_line: number; + }; + identifiers: GitLabIdentifier[]; +} + +interface GitLabSastReport { + version: '15.0.0'; + scan: { + analyzer: { + id: string; + name: string; + version: string; + vendor: { + name: string; + }; + }; + scanner: { + id: string; + name: string; + version: string; + vendor: { + name: string; + }; + }; + type: 'sast'; + start_time: string; + end_time: string; + status: 'success'; + }; + vulnerabilities: GitLabVulnerability[]; +} + +const SCANNER = { + id: 'isnad-scanner', + name: 'ISNAD Scanner', + version: '0.1.0', + vendor: { + name: 'ISNAD' + } +}; + +function mapSeverity(severity: Finding['severity']): GitLabVulnerability['severity'] { + if (severity === 'critical') return 'Critical'; + if (severity === 'high') return 'High'; + if (severity === 'medium') return 'Medium'; + return 'Low'; +} + +function mapConfidence(confidence: number): GitLabVulnerability['confidence'] { + if (confidence >= 0.8) return 'High'; + if (confidence >= 0.5) return 'Medium'; + return 'Low'; +} + +function vulnerabilityId(file: string, finding: Finding): string { + return createHash('sha256') + .update(`${file}:${finding.patternId}:${finding.line}:${finding.match}`) + .digest('hex'); +} + +function toVulnerability(file: string, result: AnalysisResult, finding: Finding): GitLabVulnerability { + return { + id: vulnerabilityId(file, finding), + category: 'sast', + name: finding.name, + message: `${finding.patternId}: ${finding.description}`, + description: finding.description, + severity: mapSeverity(finding.severity), + confidence: mapConfidence(result.confidence), + scanner: { + id: SCANNER.id, + name: SCANNER.name + }, + location: { + file, + start_line: finding.line + }, + identifiers: [ + { + type: 'isnad_pattern', + name: finding.patternId, + value: finding.patternId + } + ] + }; +} + +export function toGitLabSastReport(results: { file: string; result: AnalysisResult }[]): GitLabSastReport { + const timestamp = new Date().toISOString(); + return { + version: '15.0.0', + scan: { + analyzer: SCANNER, + scanner: SCANNER, + type: 'sast', + start_time: timestamp, + end_time: timestamp, + status: 'success' + }, + vulnerabilities: results.flatMap(({ file, result }) => + result.findings.map((finding) => toVulnerability(file, result, finding)) + ) + }; +}