Skip to content

Finding

sereto.finding

FindingGroup dataclass

Represents a finding group.

Attributes:

Name Type Description
name str

The name of the finding group.

explicit_risk Risk | None

Risk to be used for the group. Overrides the calculated risks from sub-findings.

sub_findings list[SubFinding]

A list of sub-findings in the group.

Source code in sereto/finding.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
@dataclass
class FindingGroup:
    """
    Represents a finding group.

    Attributes:
        name: The name of the finding group.
        explicit_risk: Risk to be used for the group. Overrides the calculated risks from sub-findings.
        sub_findings: A list of sub-findings in the group.
    """

    name: str
    explicit_risk: Risk | None
    sub_findings: list[SubFinding]

    @classmethod
    @validate_call
    def load(cls, name: str, group_desc: FindingGroupModel, findings_dir: DirectoryPath) -> Self:
        """
        Load a finding group.

        Args:
            name: The name of the finding group.
            group_desc: The description of the finding group.
            findings_dir: The path to the findings directory.

        Returns:
            The loaded finding group object.
        """
        sub_findings = [SubFinding.load_from(findings_dir / f"{name}.md.j2") for name in group_desc.findings]

        return cls(
            name=name,
            explicit_risk=group_desc.risk,
            sub_findings=sub_findings,
        )

    def dumps_toml(self) -> str:
        """Dump the finding group to a TOML string."""
        lines = [f'["{self.name}"]']
        if self.explicit_risk is not None:
            lines.append(f'risk = "{self.explicit_risk.value}"')
        if len(self.sub_findings) == 1:
            lines.append(f'findings = ["{self.sub_findings[0].uname}"]')
        else:
            lines.append("findings = [")
            for sf in self.sub_findings:
                lines.append(f'    "{sf.uname}",')
            lines.append("]")
        return "\n".join(lines)

    @property
    def risk(self) -> Risk:
        """
        Get the finding group risk.

        Returns:
            The explicit risk if set, otherwise the highest risk from the sub-findings.
        """
        if self.explicit_risk is not None:
            return self.explicit_risk
        return max([sf.risk for sf in self.sub_findings], key=lambda r: r.to_int())

    @property
    @validate_call
    def uname(self) -> str:
        """Unique name of the finding group."""
        return lower_alphanum(f"finding_group_{self.name}")

risk property

Get the finding group risk.

Returns:

Type Description
Risk

The explicit risk if set, otherwise the highest risk from the sub-findings.

uname property

Unique name of the finding group.

dumps_toml()

Dump the finding group to a TOML string.

Source code in sereto/finding.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
def dumps_toml(self) -> str:
    """Dump the finding group to a TOML string."""
    lines = [f'["{self.name}"]']
    if self.explicit_risk is not None:
        lines.append(f'risk = "{self.explicit_risk.value}"')
    if len(self.sub_findings) == 1:
        lines.append(f'findings = ["{self.sub_findings[0].uname}"]')
    else:
        lines.append("findings = [")
        for sf in self.sub_findings:
            lines.append(f'    "{sf.uname}",')
        lines.append("]")
    return "\n".join(lines)

load(name, group_desc, findings_dir) classmethod

Load a finding group.

Parameters:

Name Type Description Default
name str

The name of the finding group.

required
group_desc FindingGroupModel

The description of the finding group.

required
findings_dir DirectoryPath

The path to the findings directory.

required

Returns:

Type Description
Self

The loaded finding group object.

Source code in sereto/finding.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@classmethod
@validate_call
def load(cls, name: str, group_desc: FindingGroupModel, findings_dir: DirectoryPath) -> Self:
    """
    Load a finding group.

    Args:
        name: The name of the finding group.
        group_desc: The description of the finding group.
        findings_dir: The path to the findings directory.

    Returns:
        The loaded finding group object.
    """
    sub_findings = [SubFinding.load_from(findings_dir / f"{name}.md.j2") for name in group_desc.findings]

    return cls(
        name=name,
        explicit_risk=group_desc.risk,
        sub_findings=sub_findings,
    )

Findings dataclass

Represents a collection of findings.

Attributes:

Name Type Description
groups list[FindingGroup]

A list of finding groups.

target_dir FilePath

The path to the target directory containing the findings.

Source code in sereto/finding.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
@dataclass
class Findings:
    """
    Represents a collection of findings.

    Attributes:
        groups: A list of finding groups.
        target_dir: The path to the target directory containing the findings.
    """

    groups: list[FindingGroup]
    target_dir: FilePath

    @classmethod
    @validate_call
    def load_from(cls, target_dir: DirectoryPath) -> Self:
        """
        Load findings belonging to the same target.

        Args:
            target_dir: The path to the target directory.

        Returns:
            The loaded findings object.
        """
        config = FindingsConfigModel.load_from(target_dir / "findings.toml")

        groups = [
            FindingGroup.load(name=name, group_desc=group, findings_dir=target_dir / "findings")
            for name, group in config.items()
        ]

        # ensure group names are unique
        unique_names = [g.uname for g in groups]
        if len(unique_names) != len(set(unique_names)):
            raise SeretoValueError("finding group unique names must be unique")

        return cls(groups=groups, target_dir=target_dir)

    @validate_call
    def add_from_template(
        self,
        template: FilePath,
        category: str,
        name: str | None = None,
        risk: Risk | None = None,
        variables: dict[str, Any] | None = None,
    ) -> None:
        """Add a sub-finding from a template.

        This will create a new finding group with a single sub-finding.

        Args:
            template: The path to the sub-finding template.
            name: The name of the sub-finding. If not provided, the name will use the default value from the template.
            risk: The risk of the sub-finding. If not provided, the risk will use the default value from the template.
        """
        if variables is None:
            variables = {}

        # read template
        template_metadata = FindingTemplateFrontmatterModel.load_from(template)
        _, content = frontmatter.parse(template.read_text(), encoding="utf-8")

        # write sub-finding to findings directory
        if (sub_finding_path := self.findings_dir / f"{category.lower()}_{template.name}").is_file():
            raise SeretoPathError(f"sub-finding already exists: {sub_finding_path}")
        sub_finding_metadata = FindingFrontmatterModel(
            name=template_metadata.name, risk=template_metadata.risk, category=category, variables=variables
        )
        sub_finding_path.write_text(f"+++\n{sub_finding_metadata.dumps_toml()}+++\n\n{content}", encoding="utf-8")

        # load the created sub-finding
        sub_finding = SubFinding.load_from(sub_finding_path)

        # prepare finding group
        group = FindingGroup(
            name=name or sub_finding_metadata.name,
            explicit_risk=risk,
            sub_findings=[sub_finding],
        )

        # write the finding group to findings.toml
        with self.config_file.open("a", encoding="utf-8") as f:
            f.write(f"\n{group.dumps_toml()}\n")

        # add to loaded finding groups
        self.groups.append(group)

    @validate_call
    def select_group(self, selector: int | str | None = None) -> FindingGroup:
        """Select a finding group by index or name.

        Args:
            selector: The index or name of the finding group to select.

        Returns:
            The selected finding group.
        """
        # only single finding group present
        if selector is None:
            if len(self.groups) != 1:
                raise SeretoValueError(
                    f"cannot select finding group; no selector provided and there are {len(self.groups)} finding "
                    "groups present"
                )
            return self.groups[0]

        # by index
        if isinstance(selector, int) or selector.isnumeric():
            ix = selector - 1 if isinstance(selector, int) else int(selector) - 1
            if not (0 <= ix <= len(self.groups) - 1):
                raise SeretoValueError("finding group index out of range")
            return self.groups[ix]

        # by unique name
        matching_groups = [g for g in self.groups if g.uname == selector]
        if len(matching_groups) != 1:
            raise SeretoValueError(f"finding group with uname {selector!r} not found")
        return matching_groups[0]

    @property
    def config_file(self) -> Path:
        """Get the path to the findings.toml configuration file"""
        return self.target_dir / "findings.toml"

    @property
    def findings_dir(self) -> Path:
        """Get the path to the directory containing the findings"""
        return self.target_dir / "findings"

    @property
    def risks(self) -> Risks:
        """Get the summary of risks for the specified version."""
        return Risks(
            critical=len([g for g in self.groups if g.risk == Risk.critical]),
            high=len([g for g in self.groups if g.risk == Risk.high]),
            medium=len([g for g in self.groups if g.risk == Risk.medium]),
            low=len([g for g in self.groups if g.risk == Risk.low]),
            info=len([g for g in self.groups if g.risk == Risk.info]),
            closed=len([g for g in self.groups if g.risk == Risk.closed]),
        )

config_file property

Get the path to the findings.toml configuration file

findings_dir property

Get the path to the directory containing the findings

risks property

Get the summary of risks for the specified version.

add_from_template(template, category, name=None, risk=None, variables=None)

Add a sub-finding from a template.

This will create a new finding group with a single sub-finding.

Parameters:

Name Type Description Default
template FilePath

The path to the sub-finding template.

required
name str | None

The name of the sub-finding. If not provided, the name will use the default value from the template.

None
risk Risk | None

The risk of the sub-finding. If not provided, the risk will use the default value from the template.

None
Source code in sereto/finding.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
@validate_call
def add_from_template(
    self,
    template: FilePath,
    category: str,
    name: str | None = None,
    risk: Risk | None = None,
    variables: dict[str, Any] | None = None,
) -> None:
    """Add a sub-finding from a template.

    This will create a new finding group with a single sub-finding.

    Args:
        template: The path to the sub-finding template.
        name: The name of the sub-finding. If not provided, the name will use the default value from the template.
        risk: The risk of the sub-finding. If not provided, the risk will use the default value from the template.
    """
    if variables is None:
        variables = {}

    # read template
    template_metadata = FindingTemplateFrontmatterModel.load_from(template)
    _, content = frontmatter.parse(template.read_text(), encoding="utf-8")

    # write sub-finding to findings directory
    if (sub_finding_path := self.findings_dir / f"{category.lower()}_{template.name}").is_file():
        raise SeretoPathError(f"sub-finding already exists: {sub_finding_path}")
    sub_finding_metadata = FindingFrontmatterModel(
        name=template_metadata.name, risk=template_metadata.risk, category=category, variables=variables
    )
    sub_finding_path.write_text(f"+++\n{sub_finding_metadata.dumps_toml()}+++\n\n{content}", encoding="utf-8")

    # load the created sub-finding
    sub_finding = SubFinding.load_from(sub_finding_path)

    # prepare finding group
    group = FindingGroup(
        name=name or sub_finding_metadata.name,
        explicit_risk=risk,
        sub_findings=[sub_finding],
    )

    # write the finding group to findings.toml
    with self.config_file.open("a", encoding="utf-8") as f:
        f.write(f"\n{group.dumps_toml()}\n")

    # add to loaded finding groups
    self.groups.append(group)

load_from(target_dir) classmethod

Load findings belonging to the same target.

Parameters:

Name Type Description Default
target_dir DirectoryPath

The path to the target directory.

required

Returns:

Type Description
Self

The loaded findings object.

Source code in sereto/finding.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
@classmethod
@validate_call
def load_from(cls, target_dir: DirectoryPath) -> Self:
    """
    Load findings belonging to the same target.

    Args:
        target_dir: The path to the target directory.

    Returns:
        The loaded findings object.
    """
    config = FindingsConfigModel.load_from(target_dir / "findings.toml")

    groups = [
        FindingGroup.load(name=name, group_desc=group, findings_dir=target_dir / "findings")
        for name, group in config.items()
    ]

    # ensure group names are unique
    unique_names = [g.uname for g in groups]
    if len(unique_names) != len(set(unique_names)):
        raise SeretoValueError("finding group unique names must be unique")

    return cls(groups=groups, target_dir=target_dir)

select_group(selector=None)

Select a finding group by index or name.

Parameters:

Name Type Description Default
selector int | str | None

The index or name of the finding group to select.

None

Returns:

Type Description
FindingGroup

The selected finding group.

Source code in sereto/finding.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
@validate_call
def select_group(self, selector: int | str | None = None) -> FindingGroup:
    """Select a finding group by index or name.

    Args:
        selector: The index or name of the finding group to select.

    Returns:
        The selected finding group.
    """
    # only single finding group present
    if selector is None:
        if len(self.groups) != 1:
            raise SeretoValueError(
                f"cannot select finding group; no selector provided and there are {len(self.groups)} finding "
                "groups present"
            )
        return self.groups[0]

    # by index
    if isinstance(selector, int) or selector.isnumeric():
        ix = selector - 1 if isinstance(selector, int) else int(selector) - 1
        if not (0 <= ix <= len(self.groups) - 1):
            raise SeretoValueError("finding group index out of range")
        return self.groups[ix]

    # by unique name
    matching_groups = [g for g in self.groups if g.uname == selector]
    if len(matching_groups) != 1:
        raise SeretoValueError(f"finding group with uname {selector!r} not found")
    return matching_groups[0]

SubFinding dataclass

Source code in sereto/finding.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@dataclass
class SubFinding:
    name: str
    risk: Risk
    vars: dict[str, Any]
    path: FilePath

    @classmethod
    @validate_call
    def load_from(cls, path: FilePath) -> Self:
        """
        Load a sub-finding from a file.

        Args:
            path: The path to the sub-finding file.

        Returns:
            The loaded sub-finding object.
        """
        frontmatter = FindingFrontmatterModel.load_from(path)

        return cls(
            name=frontmatter.name,
            risk=frontmatter.risk,
            vars=frontmatter.variables,
            path=path,
        )

    @property
    def uname(self) -> str:
        """Unique name of the finding."""
        return self.path.name.removesuffix(".md.j2")

uname property

Unique name of the finding.

load_from(path) classmethod

Load a sub-finding from a file.

Parameters:

Name Type Description Default
path FilePath

The path to the sub-finding file.

required

Returns:

Type Description
Self

The loaded sub-finding object.

Source code in sereto/finding.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@classmethod
@validate_call
def load_from(cls, path: FilePath) -> Self:
    """
    Load a sub-finding from a file.

    Args:
        path: The path to the sub-finding file.

    Returns:
        The loaded sub-finding object.
    """
    frontmatter = FindingFrontmatterModel.load_from(path)

    return cls(
        name=frontmatter.name,
        risk=frontmatter.risk,
        vars=frontmatter.variables,
        path=path,
    )

render_finding_group_to_tex(config, project_path, target, target_ix, finding_group, finding_group_ix, version)

Render selected finding group (top-level document) to TeX format.

Source code in sereto/finding.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
def render_finding_group_to_tex(
    config: "Config",
    project_path: DirectoryPath,
    target: "Target",
    target_ix: int,
    finding_group: FindingGroup,
    finding_group_ix: int,
    version: ProjectVersion,
) -> str:
    """Render selected finding group (top-level document) to TeX format."""
    version_config = config.at_version(version=version)

    # Construct path to finding group template
    template = project_path / "layouts/finding_group.tex.j2"
    if not template.is_file():
        raise SeretoPathError(f"template not found: '{template}'")

    # Render Jinja2 template
    finding_group_generator = render_jinja2(
        templates=[
            project_path / "layouts/generated",
            project_path / "layouts",
            project_path / "includes",
            project_path,
        ],
        file=template,
        vars={
            "finding_group": finding_group,
            "finding_group_index": finding_group_ix,
            "target": target,
            "target_index": target_ix,
            "c": version_config,
            "config": config,
            "version": version,
            "project_path": project_path,
        },
    )

    return "".join(finding_group_generator)