diff --git a/README.md b/README.md index 20c9ff5..8c62b6f 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,25 @@ shieldscan/ | GET | `/api/scans/{id}/progress` | Live progress | | GET | `/api/scans/{id}/report/html` | HTML report | | GET | `/api/scans/{id}/report/download` | Markdown download | +| GET | `/api/scans/{id}/report/sarif` | SARIF v2.1.0 export (GitHub Code Scanning) | + +## SARIF Export & GitHub Code Scanning + +ShieldScan exports scan results in **SARIF v2.1.0** format (`https://json.schemastore.org/sarif-2.1.0.json`) for integration with GitHub Code Scanning and CI security gates: + +```bash +# Export SARIF report for scan #1 +curl -s http://127.0.0.1:8000/api/scans/1/report/sarif -o shieldscan-results.sarif + +# Upload to GitHub Code Scanning in CI +gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/OWNER/REPO/code-scanning/sarifs \ + -f commit_sha="$(git rev-parse HEAD)" \ + -f ref="refs/heads/main" \ + -f sarif="$(gzip -c shieldscan-results.sarif | base64 -w0)" +``` ## Development diff --git a/app/config.py b/app/config.py index 1b6514e..c61d178 100644 --- a/app/config.py +++ b/app/config.py @@ -2,6 +2,24 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +FUZZ_BUDGET_PROFILES: dict[str, dict[str, int]] = { + "quick": { + "max_params": 15, + "max_pages": 25, + "max_depth": 2, + }, + "standard": { + "max_params": 45, + "max_pages": 80, + "max_depth": 4, + }, + "deep": { + "max_params": 80, + "max_pages": 120, + "max_depth": 5, + }, +} + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -20,10 +38,21 @@ class Settings(BaseSettings): scan_spider_timeout: int = 300 scan_active_timeout: int = 600 max_scan_duration: int = 900 + fuzz_profile: str = "standard" # quick | standard | deep crawl_max_pages: int = 80 crawl_max_depth: int = 4 fuzz_max_params: int = 45 + def get_fuzz_budget(self, profile: str | None = None) -> dict[str, int]: + """Return parameter fuzz and crawl budgets for the selected profile.""" + selected = (profile or self.fuzz_profile or "standard").strip().lower() + base = dict(FUZZ_BUDGET_PROFILES.get(selected, FUZZ_BUDGET_PROFILES["standard"])) + if selected == "standard": + base["max_params"] = self.fuzz_max_params + base["max_pages"] = self.crawl_max_pages + base["max_depth"] = self.crawl_max_depth + return base + @lru_cache def get_settings() -> Settings: diff --git a/app/routers/scans.py b/app/routers/scans.py index d6659e0..2836e67 100644 --- a/app/routers/scans.py +++ b/app/routers/scans.py @@ -2,7 +2,7 @@ import json from fastapi import APIRouter, BackgroundTasks, Depends -from fastapi.responses import HTMLResponse, Response +from fastapi.responses import HTMLResponse, JSONResponse, Response from sqlalchemy.orm import Session from app.config import Settings, get_settings @@ -12,6 +12,7 @@ from app.schemas import Finding, ScanCreate, ScanDetail, ScanProgress, ScanSummary from app.services.finding_utils import enrich_findings from app.services.owasp import progress_for_status +from app.services.sarif_exporter import generate_sarif_report from app.services.scan_orchestrator import ScanOrchestrator from app.services.target_guard import validate_scan_request @@ -139,3 +140,26 @@ def download_report(scan_id: int, db: Session = Depends(get_db)): media_type="text/markdown", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) + + +@router.get("/{scan_id}/report/sarif") +def export_sarif(scan_id: int, db: Session = Depends(get_db)): + scan = db.query(Scan).filter(Scan.id == scan_id).first() + if not scan: + raise_api_error(404, "NOT_FOUND", "Scan not found") + + findings = [] + if scan.findings_json: + findings = enrich_findings([Finding(**f) for f in json.loads(scan.findings_json)]) + + sarif = generate_sarif_report( + scan_id=scan.id, + target_url=scan.target_url, + findings=findings, + ) + filename = f"shieldscan-report-{scan_id}.sarif.json" + return JSONResponse( + content=sarif, + media_type="application/sarif+json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/app/services/builtin_scanner.py b/app/services/builtin_scanner.py index b42b42b..6d2c1d3 100644 --- a/app/services/builtin_scanner.py +++ b/app/services/builtin_scanner.py @@ -7,6 +7,7 @@ from app.services.crawler import crawl_site from app.services.deep_probes import run_deep_probes from app.services.extended_checks import run_extended_checks +from app.services.finding_utils import enrich_findings from app.services.owasp_top10 import run_owasp_top10_probes from app.services.passive_checks import check_security_headers @@ -14,19 +15,13 @@ def _dedupe_findings(findings: list[Finding]) -> list[Finding]: - seen: set[str] = set() - unique: list[Finding] = [] - for f in findings: - key = f.id - if key not in seen: - seen.add(key) - unique.append(f) - return unique + return enrich_findings(findings) async def run_builtin_scan(target_url: str, on_progress=None) -> list[Finding]: """Full built-in scan: crawl, OWASP Top 10 probes, headers, extended checks.""" settings = get_settings() + budget = settings.get_fuzz_budget() findings: list[Finding] = [] async with httpx.AsyncClient( @@ -40,8 +35,8 @@ async def run_builtin_scan(target_url: str, on_progress=None) -> list[Finding]: crawl = await crawl_site( target_url, client, - max_pages=settings.crawl_max_pages, - max_depth=settings.crawl_max_depth, + max_pages=budget["max_pages"], + max_depth=budget["max_depth"], ) if on_progress: on_progress( @@ -71,11 +66,12 @@ async def run_builtin_scan(target_url: str, on_progress=None) -> list[Finding]: client, crawl, on_progress=on_progress, - fuzz_max_params=settings.fuzz_max_params, + fuzz_max_params=budget["max_params"], ) ) + deduped = _dedupe_findings(findings) if on_progress: - on_progress(f"Scan complete — {len(_dedupe_findings(findings))} findings") + on_progress(f"Scan complete — {len(deduped)} findings") - return _dedupe_findings(findings) + return deduped diff --git a/app/services/finding_utils.py b/app/services/finding_utils.py index 08541ca..d927e7d 100644 --- a/app/services/finding_utils.py +++ b/app/services/finding_utils.py @@ -1,8 +1,120 @@ -"""Normalise findings for display — fill missing parameter labels.""" +"""Normalise findings for display — fill missing parameter labels, filter false positives, and calibrate severity.""" from urllib.parse import urlparse from app.schemas import Finding +from app.services.owasp import classify_owasp + +VALID_RISKS = ("Critical", "High", "Medium", "Low", "Informational") +RISK_MAP = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "informational": "Informational", + "info": "Informational", +} + +CONFIDENCE_MAP = { + "high": "High", + "firm": "Firm", + "medium": "Medium", + "low": "Low", + "confirmed": "High", +} + + +def is_false_positive(finding: Finding) -> bool: + """Detect common scanner false positives before reporting.""" + name = (finding.name or "").lower() + ev = (finding.evidence or "").lower() + + # Reflected XSS where angle brackets were HTML-entity encoded and not executable + if "xss" in name or "cross-site scripting" in name: + if ("<script" in ev or "<svg" in ev or "<img" in ev) and "" + "