Skip to content

Commit 03514c6

Browse files
authored
Merge pull request #44 from OPPIDA/feat/sarif-report
2 parents e24c63c + 2c18822 commit 03514c6

20 files changed

Lines changed: 407 additions & 71 deletions

README.md

Lines changed: 124 additions & 37 deletions
Large diffs are not rendered by default.

codesectools/sasts/all/cli.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111
from codesectools.datasets import DATASETS_ALL
1212
from codesectools.datasets.core.dataset import FileDataset, GitRepoDataset
1313
from codesectools.sasts import SASTS_ALL
14-
from codesectools.sasts.all.report import ReportEngine
14+
from codesectools.sasts.all.report.HTML import HTMLReport
15+
from codesectools.sasts.all.report.SARIF import SARIFReport
1516
from codesectools.sasts.all.sast import AllSAST
1617
from codesectools.sasts.core.sast import PrebuiltBuildlessSAST, PrebuiltSAST
1718

19+
REPORT_FORMATS = {"HTML": HTMLReport, "SARIF": SARIFReport}
20+
1821

1922
def build_cli() -> typer.Typer:
2023
"""Build the Typer CLI for running all SAST tools."""
@@ -239,6 +242,21 @@ def report(
239242
metavar="PROJECT",
240243
),
241244
],
245+
format: Annotated[
246+
str,
247+
typer.Option(
248+
"--format",
249+
click_type=Choice(REPORT_FORMATS.keys()),
250+
help="Report format",
251+
),
252+
] = "HTML",
253+
top: Annotated[
254+
int | None,
255+
typer.Option(
256+
"--top",
257+
help="Limit to a number of files by score",
258+
),
259+
] = None,
242260
overwrite: Annotated[
243261
bool,
244262
typer.Option(
@@ -247,14 +265,16 @@ def report(
247265
),
248266
] = False,
249267
) -> None:
250-
"""Generate an HTML report for a project's aggregated analysis results.
268+
"""Generate a report for a project's aggregated analysis results.
251269
252270
Args:
253271
project: The name of the project to report on.
272+
format: The format of the report to generate (e.g., HTML, SARIF).
273+
top: The maximum number of files to include, ranked by score.
254274
overwrite: If True, overwrite existing results.
255275
256276
"""
257-
report_dir = all_sast.output_dir / project / "report"
277+
report_dir = all_sast.output_dir / project / "report" / format
258278
if report_dir.is_dir():
259279
if overwrite:
260280
shutil.rmtree(report_dir)
@@ -265,7 +285,9 @@ def report(
265285

266286
report_dir.mkdir(parents=True)
267287

268-
report_engine = ReportEngine(project=project, all_sast=all_sast)
288+
report_engine = REPORT_FORMATS[format](
289+
project=project, all_sast=all_sast, top=top
290+
)
269291
report_engine.generate()
270292
print(f"Report generated at {report_dir.resolve()}")
271293

codesectools/sasts/all/parser.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,16 @@ def stats_by_scores(self) -> dict:
207207
}
208208
return stats
209209

210-
def prepare_report_data(self) -> dict:
211-
"""Prepare data needed to generate a report."""
210+
def prepare_report_data(self, top: int | None = None) -> dict:
211+
"""Prepare data needed to generate a report.
212+
213+
Args:
214+
top: The maximum number of files to include, ranked by score.
215+
216+
Returns:
217+
A dictionary containing the prepared report data.
218+
219+
"""
212220
report = {}
213221
scores = self.stats_by_scores()
214222

@@ -234,13 +242,21 @@ def prepare_report_data(self) -> dict:
234242
"defects": defects,
235243
}
236244

245+
if top:
246+
min_score = sorted([v["score"] for v in report.values()], reverse=True)[
247+
min(top, len(report) - 1)
248+
]
249+
else:
250+
min_score = 0
251+
237252
report = {
238253
k: v
239254
for k, v in sorted(
240255
report.items(),
241256
key=lambda item: item[1]["score"],
242257
reverse=True,
243258
)
259+
if v["score"] >= min_score
244260
}
245261

246262
return report
Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
from hashlib import sha256
55
from pathlib import Path
66

7-
from codesectools.sasts.all.sast import AllSAST
7+
from codesectools.sasts.all.report import Report
88
from codesectools.utils import group_successive
99

1010

11-
class ReportEngine:
11+
class HTMLReport(Report):
1212
"""Generate interactive HTML reports for SAST analysis results.
1313
1414
Attributes:
@@ -21,6 +21,8 @@ class ReportEngine:
2121
2222
"""
2323

24+
format = "HTML"
25+
2426
TEMPLATE = """
2527
<!DOCTYPE html>
2628
<html>
@@ -65,21 +67,6 @@ class ReportEngine:
6567
</html>
6668
"""
6769

68-
def __init__(self, project: str, all_sast: AllSAST) -> None:
69-
"""Initialize the ReportEngine.
70-
71-
Args:
72-
project: The name of the project.
73-
all_sast: The AllSAST instance.
74-
75-
"""
76-
self.project = project
77-
self.all_sast = all_sast
78-
self.report_dir = all_sast.output_dir / project / "report"
79-
80-
self.result = all_sast.parser.load_from_output_dir(project_name=project)
81-
self.report_data = self.result.prepare_report_data()
82-
8370
def generate_single_defect(self, defect_file: dict) -> str:
8471
"""Generate the HTML report for a single file with defects."""
8572
from rich.console import Console
@@ -187,11 +174,7 @@ def generate_single_defect(self, defect_file: dict) -> str:
187174
return html_content
188175

189176
def generate(self) -> None:
190-
"""Generate the HTML report.
191-
192-
Creates the report directory and generates HTML files for the main view
193-
and for each file with defects.
194-
"""
177+
"""Generate the HTML report."""
195178
from rich.console import Console
196179
from rich.progress import track
197180
from rich.style import Style
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Generates SARIF reports for aggregated SAST analysis results."""
2+
3+
from typing import Optional
4+
5+
from codesectools.sasts.all.report import Report
6+
from codesectools.sasts.core.parser.format.SARIF import (
7+
ArtifactLocation,
8+
Location,
9+
Message,
10+
PhysicalLocation,
11+
PropertyBag,
12+
Region,
13+
ReportingDescriptor,
14+
Result,
15+
Run,
16+
Tool,
17+
ToolComponent,
18+
)
19+
from codesectools.sasts.core.parser.format.SARIF import (
20+
StaticAnalysisResultsFormatSarifVersion210JsonSchema as SARIF,
21+
)
22+
23+
24+
class SARIFReport(Report):
25+
"""Generate SARIF reports for SAST analysis results.
26+
27+
Attributes:
28+
format (str): The format of the report, which is "SARIF".
29+
project (str): The name of the project.
30+
all_sast (AllSAST): The AllSAST manager instance.
31+
report_dir (Path): The directory where reports are saved.
32+
result (AllSASTAnalysisResult): The parsed analysis results.
33+
report_data (dict): The data prepared for rendering the report.
34+
35+
"""
36+
37+
format = "SARIF"
38+
39+
def generate(self) -> None:
40+
"""Generate the SARIF report."""
41+
included_defect_file = self.report_data.keys()
42+
43+
runs: list[Run] = []
44+
for analysis_result in self.result.analysis_results.values():
45+
results: list[Result] = []
46+
rules: list[ReportingDescriptor] = []
47+
rule_ids = set()
48+
49+
for defect in analysis_result.defects:
50+
if defect.filepath_str not in included_defect_file:
51+
continue
52+
53+
relative_uri = defect.filepath.relative_to(
54+
analysis_result.source_path
55+
).as_posix()
56+
57+
region: Optional[Region] = None
58+
if defect.lines:
59+
start_line_num = min(defect.lines)
60+
end_line_num = (
61+
max(defect.lines) if len(defect.lines) > 1 else start_line_num
62+
)
63+
64+
region = Region(start_line=start_line_num, end_line=end_line_num)
65+
66+
physical_location = PhysicalLocation(
67+
artifact_location=ArtifactLocation(
68+
uri=relative_uri, uri_base_id="%SRCROOT%"
69+
),
70+
region=region,
71+
)
72+
73+
result = Result(
74+
rule_id=defect.checker,
75+
level=defect.level,
76+
message=Message(text=defect.message),
77+
locations=[Location(physical_location=physical_location)], # ty:ignore[missing-argument]
78+
properties=PropertyBag(
79+
__root__={"cwe": str(defect.cwe)} # ty:ignore[unknown-argument]
80+
),
81+
) # ty:ignore[missing-argument]
82+
results.append(result)
83+
84+
if defect.checker not in rule_ids:
85+
rules.append(ReportingDescriptor(id=defect.checker)) # ty:ignore[missing-argument]
86+
rule_ids.add(defect.checker)
87+
88+
tool = Tool(
89+
driver=ToolComponent(
90+
name=analysis_result.sast_name,
91+
rules=rules,
92+
) # ty:ignore[missing-argument]
93+
) # ty:ignore[missing-argument]
94+
95+
run = Run(
96+
tool=tool,
97+
results=results,
98+
original_uri_base_ids={
99+
"%SRCROOT%": ArtifactLocation(
100+
uri=analysis_result.source_path.resolve().as_uri()
101+
)
102+
},
103+
properties=PropertyBag(
104+
__root__={
105+
"lines_of_codes": analysis_result.lines_of_codes,
106+
"analysis_time_seconds": analysis_result.time,
107+
"language": analysis_result.lang,
108+
} # ty:ignore[unknown-argument]
109+
),
110+
) # ty:ignore[missing-argument]
111+
112+
runs.append(run)
113+
114+
sarif_report = SARIF(
115+
version="2.1.0",
116+
runs=runs,
117+
)
118+
119+
sarif_file = (self.report_dir / self.result.name).with_suffix(".sarif")
120+
sarif_file.write_text(
121+
sarif_report.model_dump_json(by_alias=True, exclude_none=True, indent=2)
122+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Defines the base report generation functionality for aggregated SAST results."""
2+
3+
from abc import ABC, abstractmethod
4+
5+
from codesectools.sasts.all.sast import AllSAST
6+
7+
8+
class Report(ABC):
9+
"""Abstract base class for report generation.
10+
11+
Attributes:
12+
format (str): The format of the report (e.g., "HTML", "SARIF").
13+
project (str): The name of the project.
14+
all_sast (AllSAST): The AllSAST manager instance.
15+
report_dir (Path): The directory where reports are saved.
16+
result (AllSASTAnalysisResult): The parsed analysis results.
17+
report_data (dict): The data prepared for rendering the report.
18+
19+
"""
20+
21+
format: str
22+
23+
def __init__(self, project: str, all_sast: AllSAST, top: int | None = None) -> None:
24+
"""Initialize the Report.
25+
26+
Args:
27+
project: The name of the project.
28+
all_sast: The AllSAST instance.
29+
top: The number of top files to include in the report based on score.
30+
31+
"""
32+
self.project = project
33+
self.all_sast = all_sast
34+
self.report_dir = all_sast.output_dir / project / "report" / self.format
35+
36+
self.result = all_sast.parser.load_from_output_dir(project_name=project)
37+
self.report_data = self.result.prepare_report_data(top=top)
38+
39+
@abstractmethod
40+
def generate(self) -> None:
41+
"""Generate the report."""
42+
pass

codesectools/sasts/core/parser/format/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ datamodel-codegen \
1919
--use-union-operator \
2020
--target-python-version 3.12 \
2121
--enum-field-as-literal all \
22+
--allow-population-by-field-name \
2223
--custom-file-header '"""Static Analysis Results Interchange Format (SARIF) Version 2.1.0 data model."""'
2324

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

0 commit comments

Comments
 (0)