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
42 changes: 2 additions & 40 deletions .github/actions/security-scan/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -334,46 +334,8 @@ runs:
done
else
# Full project mode: scan entire project
# We need to scan individual files since scan-project does not output SARIF
agent-security-scanner-mcp scan-project . --verbosity full > /tmp/project-scan.json 2>&1 || true

python3 -c "
import json
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': 'agent-security-scanner-mcp',
'version': '3.3.0',
'informationUri': 'https://github.com/sinewaveai/agent-security-scanner-mcp',
'rules': []
}
},
'results': []
}]
}
try:
data = json.load(open('/tmp/project-scan.json'))
for f in data.get('files', []):
filepath = f.get('file', '')
for issue in f.get('issues', []):
sarif['runs'][0]['results'].append({
'ruleId': issue.get('ruleId', 'unknown'),
'level': 'error' if issue.get('severity') == 'ERROR' else 'warning',
'message': {'text': issue.get('message', 'Security issue detected')},
'locations': [{
'physicalLocation': {
'artifactLocation': {'uri': filepath},
'region': {'startLine': issue.get('line', 1)}
}
}]
})
except Exception:
pass
json.dump(sarif, open('results.sarif', 'w'), indent=2)
"
agent-security-scanner-mcp scan-project . --verbosity full > /tmp/project-scan.json 2> /tmp/project-scan.stderr || true
python3 "${{ github.action_path }}/../../../scripts/project_scan_to_sarif.py" /tmp/project-scan.json results.sarif || true
fi

echo "::endgroup::"
Expand Down
143 changes: 143 additions & 0 deletions scripts/project_scan_to_sarif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Convert scan-project JSON output to SARIF 2.1.0.

Supports both:
- Current flat schema: { issues: [{ file, line, ruleId, severity, message }, ...] }
- Legacy nested schema: { files: [{ file, issues: [...] }, ...] }
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

SCHEMA_URL = (
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/"
"sarif-2.1/schema/sarif-schema-2.1.0.json"
)


def _base_sarif() -> dict:
return {
"$schema": SCHEMA_URL,
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "agent-security-scanner-mcp",
"version": "3.3.0",
"informationUri": "https://github.com/sinewaveai/agent-security-scanner-mcp",
"rules": [],
}
},
"results": [],
}
],
}


def _sarif_level(severity: str | None) -> str:
sev = (severity or "").upper()
if sev == "ERROR":
return "error"
if sev == "WARNING":
return "warning"
return "note"


def _normalize_line(line_value) -> int:
try:
line = int(line_value)
return max(1, line)
except Exception: # noqa: BLE001
return 1


def _append_issue(results: list, issue: dict, fallback_file: str = "") -> None:
if not isinstance(issue, dict):
return
rule_id = issue.get("ruleId", "unknown")
message = issue.get("message", "Security issue detected")
file_path = issue.get("file") or issue.get("filePath") or fallback_file or ""
start_line = _normalize_line(issue.get("line", 1))

results.append(
{
"ruleId": rule_id,
"level": _sarif_level(issue.get("severity")),
"message": {"text": message},
"locations": [
{
"physicalLocation": {
"artifactLocation": {"uri": file_path},
"region": {"startLine": start_line},
}
}
],
}
)


def convert(project_scan: dict) -> dict:
sarif = _base_sarif()
run = sarif["runs"][0]
results = run["results"]

# Preferred path: current flat schema.
issues = project_scan.get("issues", [])
if isinstance(issues, list):
for issue in issues:
_append_issue(results, issue)

# Backward compatibility: legacy nested files schema.
files = project_scan.get("files", [])
if isinstance(files, list):
for file_item in files:
if not isinstance(file_item, dict):
continue
file_path = file_item.get("file", "")
nested = file_item.get("issues", [])
if isinstance(nested, list):
for issue in nested:
_append_issue(results, issue, fallback_file=file_path)

# Add a minimal rule catalog for discovered rules.
seen_rules = set()
for result in results:
rid = result.get("ruleId")
if rid and rid not in seen_rules:
seen_rules.add(rid)
run["tool"]["driver"]["rules"].append(
{"id": rid, "name": rid, "shortDescription": {"text": rid}}
)

return sarif


def main() -> int:
if len(sys.argv) != 3:
print("usage: project_scan_to_sarif.py <project-scan.json> <results.sarif>", file=sys.stderr)
return 2

in_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
sarif = _base_sarif()

try:
data = json.loads(in_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
sarif = convert(data)
else:
print("project scan JSON must be an object; writing empty SARIF", file=sys.stderr)
except Exception as exc: # noqa: BLE001
print(f"warning: failed to parse project scan JSON ({exc}); writing empty SARIF", file=sys.stderr)

out_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8")
return 0


if __name__ == "__main__":
raise SystemExit(main())
155 changes: 155 additions & 0 deletions tests/project-scan-to-sarif.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

const SCRIPT = join(process.cwd(), 'scripts', 'project_scan_to_sarif.py');

function convert(inputJson) {
const dir = mkdtempSync(join(tmpdir(), 'sarif-convert-'));
const inFile = join(dir, 'project-scan.json');
const outFile = join(dir, 'results.sarif');
try {
writeFileSync(inFile, JSON.stringify(inputJson), 'utf-8');
execFileSync('python3', [SCRIPT, inFile, outFile], { encoding: 'utf-8' });
return JSON.parse(readFileSync(outFile, 'utf-8'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

describe('project_scan_to_sarif.py', () => {
it('converts current flat issues[] schema', () => {
const sarif = convert({
issues_count: 2,
issues: [
{
file: 'src/a.js',
line: 9,
ruleId: 'javascript.security.sql-injection',
severity: 'error',
message: 'SQL injection',
},
{
file: 'src/b.py',
line: 3,
ruleId: 'python.security.eval-use',
severity: 'WARNING',
message: 'Eval usage',
},
],
});

const run = sarif.runs[0];
expect(run.results).toHaveLength(2);
expect(run.results[0].ruleId).toBe('javascript.security.sql-injection');
expect(run.results[0].level).toBe('error');
expect(run.results[0].locations[0].physicalLocation.artifactLocation.uri).toBe('src/a.js');
expect(run.results[0].locations[0].physicalLocation.region.startLine).toBe(9);
expect(run.results[1].level).toBe('warning');
expect(run.tool.driver.rules.length).toBeGreaterThanOrEqual(2);
});

it('converts legacy nested files[].issues[] schema', () => {
const sarif = convert({
files: [
{
file: 'legacy/c.js',
issues: [
{
line: 12,
ruleId: 'c.security.insecure-use-gets-fn',
severity: 'ERROR',
message: 'gets() is unsafe',
},
],
},
],
});

const run = sarif.runs[0];
expect(run.results).toHaveLength(1);
expect(run.results[0].ruleId).toBe('c.security.insecure-use-gets-fn');
expect(run.results[0].level).toBe('error');
expect(run.results[0].locations[0].physicalLocation.artifactLocation.uri).toBe('legacy/c.js');
});

it('writes valid empty SARIF for malformed input', () => {
const dir = mkdtempSync(join(tmpdir(), 'sarif-convert-'));
const inFile = join(dir, 'project-scan.json');
const outFile = join(dir, 'results.sarif');
try {
writeFileSync(inFile, 'not-json', 'utf-8');
execFileSync('python3', [SCRIPT, inFile, outFile], { encoding: 'utf-8' });
const sarif = JSON.parse(readFileSync(outFile, 'utf-8'));
expect(sarif.version).toBe('2.1.0');
expect(Array.isArray(sarif.runs)).toBe(true);
expect(sarif.runs[0].results).toHaveLength(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it('uses filePath when file is not present', () => {
const sarif = convert({
issues: [
{
filePath: 'src/from-filepath.ts',
line: 7,
ruleId: 'typescript.react.security.react-insecure-request',
severity: 'WARNING',
message: 'Unsafe request',
},
],
});

const result = sarif.runs[0].results[0];
expect(result.locations[0].physicalLocation.artifactLocation.uri).toBe('src/from-filepath.ts');
});

it('normalizes invalid/negative line numbers to 1 and maps unknown severity to note', () => {
const sarif = convert({
issues: [
{
file: 'src/weird.js',
line: -99,
ruleId: 'javascript.security.custom',
severity: 'SOMETHING-ELSE',
message: 'Custom finding',
},
],
});

const result = sarif.runs[0].results[0];
expect(result.level).toBe('note');
expect(result.locations[0].physicalLocation.region.startLine).toBe(1);
});

it('deduplicates rule catalog entries while keeping all results', () => {
const sarif = convert({
issues_count: 2,
issues: [
{
file: 'src/a.js',
line: 1,
ruleId: 'javascript.security.sql-injection',
severity: 'ERROR',
message: 'A',
},
{
file: 'src/b.js',
line: 2,
ruleId: 'javascript.security.sql-injection',
severity: 'ERROR',
message: 'B',
},
],
});

const run = sarif.runs[0];
expect(run.results).toHaveLength(2);
expect(run.tool.driver.rules).toHaveLength(1);
expect(run.tool.driver.rules[0].id).toBe('javascript.security.sql-injection');
});
});