Skip to content
Merged
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
161 changes: 124 additions & 37 deletions README.md

Large diffs are not rendered by default.

30 changes: 26 additions & 4 deletions codesectools/sasts/all/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
from codesectools.datasets import DATASETS_ALL
from codesectools.datasets.core.dataset import FileDataset, GitRepoDataset
from codesectools.sasts import SASTS_ALL
from codesectools.sasts.all.report import ReportEngine
from codesectools.sasts.all.report.HTML import HTMLReport
from codesectools.sasts.all.report.SARIF import SARIFReport
from codesectools.sasts.all.sast import AllSAST
from codesectools.sasts.core.sast import PrebuiltBuildlessSAST, PrebuiltSAST

REPORT_FORMATS = {"HTML": HTMLReport, "SARIF": SARIFReport}


def build_cli() -> typer.Typer:
"""Build the Typer CLI for running all SAST tools."""
Expand Down Expand Up @@ -239,6 +242,21 @@ def report(
metavar="PROJECT",
),
],
format: Annotated[
str,
typer.Option(
"--format",
click_type=Choice(REPORT_FORMATS.keys()),
help="Report format",
),
] = "HTML",
top: Annotated[
int | None,
typer.Option(
"--top",
help="Limit to a number of files by score",
),
] = None,
overwrite: Annotated[
bool,
typer.Option(
Expand All @@ -247,14 +265,16 @@ def report(
),
] = False,
) -> None:
"""Generate an HTML report for a project's aggregated analysis results.
"""Generate a report for a project's aggregated analysis results.

Args:
project: The name of the project to report on.
format: The format of the report to generate (e.g., HTML, SARIF).
top: The maximum number of files to include, ranked by score.
overwrite: If True, overwrite existing results.

"""
report_dir = all_sast.output_dir / project / "report"
report_dir = all_sast.output_dir / project / "report" / format
if report_dir.is_dir():
if overwrite:
shutil.rmtree(report_dir)
Expand All @@ -265,7 +285,9 @@ def report(

report_dir.mkdir(parents=True)

report_engine = ReportEngine(project=project, all_sast=all_sast)
report_engine = REPORT_FORMATS[format](
project=project, all_sast=all_sast, top=top
)
report_engine.generate()
print(f"Report generated at {report_dir.resolve()}")

Expand Down
20 changes: 18 additions & 2 deletions codesectools/sasts/all/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,16 @@ def stats_by_scores(self) -> dict:
}
return stats

def prepare_report_data(self) -> dict:
"""Prepare data needed to generate a report."""
def prepare_report_data(self, top: int | None = None) -> dict:
"""Prepare data needed to generate a report.

Args:
top: The maximum number of files to include, ranked by score.

Returns:
A dictionary containing the prepared report data.

"""
report = {}
scores = self.stats_by_scores()

Expand All @@ -234,13 +242,21 @@ def prepare_report_data(self) -> dict:
"defects": defects,
}

if top:
min_score = sorted([v["score"] for v in report.values()], reverse=True)[
min(top, len(report) - 1)
]
else:
min_score = 0

report = {
k: v
for k, v in sorted(
report.items(),
key=lambda item: item[1]["score"],
reverse=True,
)
if v["score"] >= min_score
}

return report
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
from hashlib import sha256
from pathlib import Path

from codesectools.sasts.all.sast import AllSAST
from codesectools.sasts.all.report import Report
from codesectools.utils import group_successive


class ReportEngine:
class HTMLReport(Report):
"""Generate interactive HTML reports for SAST analysis results.

Attributes:
Expand All @@ -21,6 +21,8 @@ class ReportEngine:

"""

format = "HTML"

TEMPLATE = """
<!DOCTYPE html>
<html>
Expand Down Expand Up @@ -65,21 +67,6 @@ class ReportEngine:
</html>
"""

def __init__(self, project: str, all_sast: AllSAST) -> None:
"""Initialize the ReportEngine.

Args:
project: The name of the project.
all_sast: The AllSAST instance.

"""
self.project = project
self.all_sast = all_sast
self.report_dir = all_sast.output_dir / project / "report"

self.result = all_sast.parser.load_from_output_dir(project_name=project)
self.report_data = self.result.prepare_report_data()

def generate_single_defect(self, defect_file: dict) -> str:
"""Generate the HTML report for a single file with defects."""
from rich.console import Console
Expand Down Expand Up @@ -187,11 +174,7 @@ def generate_single_defect(self, defect_file: dict) -> str:
return html_content

def generate(self) -> None:
"""Generate the HTML report.

Creates the report directory and generates HTML files for the main view
and for each file with defects.
"""
"""Generate the HTML report."""
from rich.console import Console
from rich.progress import track
from rich.style import Style
Expand Down
122 changes: 122 additions & 0 deletions codesectools/sasts/all/report/SARIF.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Generates SARIF reports for aggregated SAST analysis results."""

from typing import Optional

from codesectools.sasts.all.report import Report
from codesectools.sasts.core.parser.format.SARIF import (
ArtifactLocation,
Location,
Message,
PhysicalLocation,
PropertyBag,
Region,
ReportingDescriptor,
Result,
Run,
Tool,
ToolComponent,
)
from codesectools.sasts.core.parser.format.SARIF import (
StaticAnalysisResultsFormatSarifVersion210JsonSchema as SARIF,
)


class SARIFReport(Report):
"""Generate SARIF reports for SAST analysis results.

Attributes:
format (str): The format of the report, which is "SARIF".
project (str): The name of the project.
all_sast (AllSAST): The AllSAST manager instance.
report_dir (Path): The directory where reports are saved.
result (AllSASTAnalysisResult): The parsed analysis results.
report_data (dict): The data prepared for rendering the report.

"""

format = "SARIF"

def generate(self) -> None:
"""Generate the SARIF report."""
included_defect_file = self.report_data.keys()

runs: list[Run] = []
for analysis_result in self.result.analysis_results.values():
results: list[Result] = []
rules: list[ReportingDescriptor] = []
rule_ids = set()

for defect in analysis_result.defects:
if defect.filepath_str not in included_defect_file:
continue

relative_uri = defect.filepath.relative_to(
analysis_result.source_path
).as_posix()

region: Optional[Region] = None
if defect.lines:
start_line_num = min(defect.lines)
end_line_num = (
max(defect.lines) if len(defect.lines) > 1 else start_line_num
)

region = Region(start_line=start_line_num, end_line=end_line_num)

physical_location = PhysicalLocation(
artifact_location=ArtifactLocation(
uri=relative_uri, uri_base_id="%SRCROOT%"
),
region=region,
)

result = Result(
rule_id=defect.checker,
level=defect.level,
message=Message(text=defect.message),
locations=[Location(physical_location=physical_location)], # ty:ignore[missing-argument]
properties=PropertyBag(
__root__={"cwe": str(defect.cwe)} # ty:ignore[unknown-argument]
),
) # ty:ignore[missing-argument]
results.append(result)

if defect.checker not in rule_ids:
rules.append(ReportingDescriptor(id=defect.checker)) # ty:ignore[missing-argument]
rule_ids.add(defect.checker)

tool = Tool(
driver=ToolComponent(
name=analysis_result.sast_name,
rules=rules,
) # ty:ignore[missing-argument]
) # ty:ignore[missing-argument]

run = Run(
tool=tool,
results=results,
original_uri_base_ids={
"%SRCROOT%": ArtifactLocation(
uri=analysis_result.source_path.resolve().as_uri()
)
},
properties=PropertyBag(
__root__={
"lines_of_codes": analysis_result.lines_of_codes,
"analysis_time_seconds": analysis_result.time,
"language": analysis_result.lang,
} # ty:ignore[unknown-argument]
),
) # ty:ignore[missing-argument]

runs.append(run)

sarif_report = SARIF(
version="2.1.0",
runs=runs,
)

sarif_file = (self.report_dir / self.result.name).with_suffix(".sarif")
sarif_file.write_text(
sarif_report.model_dump_json(by_alias=True, exclude_none=True, indent=2)
)
42 changes: 42 additions & 0 deletions codesectools/sasts/all/report/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Defines the base report generation functionality for aggregated SAST results."""

from abc import ABC, abstractmethod

from codesectools.sasts.all.sast import AllSAST


class Report(ABC):
"""Abstract base class for report generation.

Attributes:
format (str): The format of the report (e.g., "HTML", "SARIF").
project (str): The name of the project.
all_sast (AllSAST): The AllSAST manager instance.
report_dir (Path): The directory where reports are saved.
result (AllSASTAnalysisResult): The parsed analysis results.
report_data (dict): The data prepared for rendering the report.

"""

format: str

def __init__(self, project: str, all_sast: AllSAST, top: int | None = None) -> None:
"""Initialize the Report.

Args:
project: The name of the project.
all_sast: The AllSAST instance.
top: The number of top files to include in the report based on score.

"""
self.project = project
self.all_sast = all_sast
self.report_dir = all_sast.output_dir / project / "report" / self.format

self.result = all_sast.parser.load_from_output_dir(project_name=project)
self.report_data = self.result.prepare_report_data(top=top)

@abstractmethod
def generate(self) -> None:
"""Generate the report."""
pass
2 changes: 2 additions & 0 deletions codesectools/sasts/core/parser/format/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ datamodel-codegen \
--use-union-operator \
--target-python-version 3.12 \
--enum-field-as-literal all \
--allow-population-by-field-name \
--custom-file-header '"""Static Analysis Results Interchange Format (SARIF) Version 2.1.0 data model."""'

ruff format SARIF.py
Expand All @@ -44,6 +45,7 @@ datamodel-codegen \
--use-union-operator \
--target-python-version 3.12 \
--enum-field-as-literal all \
--allow-population-by-field-name \
--class-name CoverityJsonOutputV10 \
--custom-file-header '"""Coverity JSON Output V10 model."""'

Expand Down
Loading