Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions docs/GITLAB_CI.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions scanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 35 additions & 1 deletion scanner/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -239,4 +240,37 @@ program
}
});

// GitLab SAST command
program
.command('gitlab-sast')
.description('Scan files and write a GitLab SAST report')
.argument('<pattern>', 'Glob pattern for files to scan')
.option('-o, --output <file>', '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();
27 changes: 27 additions & 0 deletions scanner/src/gitlab.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
127 changes: 127 additions & 0 deletions scanner/src/gitlab.ts
Original file line number Diff line number Diff line change
@@ -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))
)
};
}