Write a reporter plugin¶
This page builds findings-list, a reporter plugin that writes ash.findings.txt: the number of actionable findings, then one line per finding with its scanner, rule, and location. A reporter receives the aggregated results after every scanner has run and returns the text of its report; ASH writes the file.
Prerequisites:
- ASH installed (Install ASH);
- a repository to scan, with your shell in its top directory. To keep the steps short, this page saves the plugin package in an
ash-pluginsdirectory there; a plugin you keep belongs outside the repositories you scan (Package a third-party plugin).
Plugins explains how ASH loads plugin packages and what a loaded plugin can do.
1. Write the plugin¶
Save this as ash-plugins/acme_ash_plugins/__init__.py:
from typing import Literal
from automated_security_helper.base.options import ReporterOptionsBase
from automated_security_helper.base.reporter_plugin import (
ReporterPluginBase,
ReporterPluginConfigBase,
)
from automated_security_helper.models.asharp_model import AshAggregatedResults
from automated_security_helper.plugins.decorators import ash_reporter_plugin
class FindingsListOptions(ReporterOptionsBase):
include_suppressed: bool = False
class FindingsListConfig(ReporterPluginConfigBase):
name: Literal["findings-list"] = "findings-list"
extension: str = "findings.txt"
enabled: bool = True
options: FindingsListOptions = FindingsListOptions()
@ash_reporter_plugin
class FindingsListReporter(ReporterPluginBase[FindingsListConfig]):
def model_post_init(self, context):
if self.config is None:
self.config = FindingsListConfig()
super().model_post_init(context)
def report(self, model: AshAggregatedResults) -> str:
stats = model.metadata.summary_stats
lines = [f"{stats.actionable} actionable, {stats.total} total"]
for finding in model.to_flat_vulnerabilities():
if finding.is_suppressed and not self.config.options.include_suppressed:
continue
lines.append(
f"{finding.scanner}\t{finding.rule_id}\t"
f"{finding.file_path}:{finding.line_start}"
)
return "\n".join(lines) + "\n"
ASH_REPORTERS = [FindingsListReporter]
2. Check that ASH finds it¶
The reporters table has a row for the plugin:
3. Scan with it¶
A loaded reporter runs in every scan, beside the built-in ones. This scan uses only detect-secrets, which needs no download:
In a repository with one hardcoded password, the scan exits with code 2. Read the report:
It reads:
To see the report again from the last scan's results without scanning, use ash report, which takes the module through a config override. It prints the report to the terminal and writes no file:
ash scan --output-formats and the output_formats config key cannot select this reporter: they accept only the names of the reporters that ship with ASH, and when either names any, only the reporters it names run. --output-formats refuses the name of another reporter as a usage error, with exit code 5, and the config key refuses it as an invalid configuration, with exit code 3:
ash scan --ash-plugin-modules acme_ash_plugins --scanners detect-secrets --offline \
--output-formats findings-list
The reporter's options go under its name, findings-list, in the reporters section of an ASH config, where enabled: false leaves it out of a scan:
project_name: my-service
ash_plugin_modules: [acme_ash_plugins]
reporters:
findings-list:
enabled: true
options:
include_suppressed: true
ASH checks that section with FindingsListOptions once it has loaded the module, before anything is scanned: an option the class does not declare, or a value of the wrong type, refuses the configuration with exit code 3, as for a scanner plugin (Write a scanner plugin).
What ASH does with the return value¶
ASH calls report(model) once per scan, in the report phase, and handles what it returns:
report() |
Result |
|---|---|
| returns a non-empty string | ASH writes it to .ash/ash_output/reports/ash.<extension>, replacing any file there |
returns None |
an error is logged and no file is written |
| returns an empty string | a warning is logged and no file is written |
| raises an exception | the error is logged and the other reporters still run |
None of these changes the scan's exit code, which is decided by the findings and by whether the scan was complete.
Set extension in the config class. It is the end of the file name, so it must be a plain suffix such as findings.txt: a value with /, \, or .., from any config, is replaced by the default with a warning, and a reporter with no extension writes ash.txt. To write a second file beside the report, call write_report_file(report_dir, filename, content) from automated_security_helper.base.reporter_plugin with report_dir set to Path(self.context.output_dir) / "reports". It writes only inside that directory and does not follow a symbolic link at the file name.
Read the results model¶
model is an AshAggregatedResults, the same data as .ash/ash_output/ash_aggregated_results.json (Output files). The parts a reporter usually needs:
model.metadata.summary_stats:actionable,total(findings not suppressed), andseverity_countswithcritical,high,medium,low,info, andsuppressed;model.to_flat_vulnerabilities(): one flat record per finding, withscanner,rule_id,severity,file_path,line_start,line_end,description, andis_suppressed. The flatseverityis the severity the summary and the gate use (Set the severity);model.sarif: the aggregated SARIF, one run with every scanner's results;model.scanner_results: the outcome of each scanner, withstatus(PASSED, FAILED, MISSING, SKIPPED, or ERROR) andfinding_count.
Test the reporter¶
A reporter is a class, so a test can build it, give it a results model, and check the text it returns. This test builds FindingsListReporter and gives it a model with no findings, so the report is the header line alone. The package from step 1 is not installed, so the test imports it by name. Run pytest with PYTHONPATH set as in step 2:
import importlib
from pathlib import Path
from automated_security_helper.base.plugin_context import PluginContext
from automated_security_helper.models.asharp_model import AshAggregatedResults
def test_findings_list_reporter():
plugin = importlib.import_module("acme_ash_plugins")
context = PluginContext(source_dir=Path("."), output_dir=Path("out"))
reporter = plugin.FindingsListReporter(context=context)
report = reporter.report(AshAggregatedResults())
assert report == "0 actionable, 0 total\n"
A reporter that sends findings somewhere¶
The plugin from step 1 writes a local report. A reporter can also send the results elsewhere. This one uploads them to an S3 bucket and returns the object's s3:// URL, which ASH writes as the report. Its region, profile, and bucket default to values from the environment of the process that runs ASH, and validate_plugin_dependencies() checks that it can reach the bucket before the scan uses it. Add it to the package from step 1, and list FindingsBucketReporter in the reporter list of __init__.py beside FindingsListReporter:
import json
import logging
import os
from datetime import datetime, timezone
from typing import ClassVar, Literal, Optional, Tuple
import boto3
from automated_security_helper.base.options import ReporterOptionsBase
from automated_security_helper.base.reporter_plugin import (
ReporterPluginBase,
ReporterPluginConfigBase,
)
from automated_security_helper.models.asharp_model import AshAggregatedResults
from automated_security_helper.plugins.decorators import ash_reporter_plugin
from automated_security_helper.utils.log import ASH_LOGGER
class FindingsBucketOptions(ReporterOptionsBase):
aws_region: Optional[str] = os.environ.get("AWS_REGION")
aws_profile: Optional[str] = os.environ.get("AWS_PROFILE")
bucket_name: Optional[str] = os.environ.get("ACME_FINDINGS_BUCKET")
key_prefix: str = "ash-reports/"
class FindingsBucketConfig(ReporterPluginConfigBase):
name: Literal["findings-bucket"] = "findings-bucket"
extension: str = "bucket.txt"
enabled: bool = False
options: FindingsBucketOptions = FindingsBucketOptions()
@ash_reporter_plugin
class FindingsBucketReporter(ReporterPluginBase[FindingsBucketConfig]):
destination_options: ClassVar[Tuple[str, ...]] = (
"aws_region",
"aws_profile",
"bucket_name",
"key_prefix",
)
def model_post_init(self, context):
if self.config is None:
self.config = FindingsBucketConfig()
super().model_post_init(context)
def validate_plugin_dependencies(self) -> bool:
options = self.config.options
self.dependencies_satisfied = False
if options.aws_region is None or options.bucket_name is None:
return self.dependencies_satisfied
try:
session = boto3.Session(
profile_name=options.aws_profile, region_name=options.aws_region
)
caller = session.client("sts").get_caller_identity()
session.client("s3").head_bucket(Bucket=options.bucket_name)
self.dependencies_satisfied = "Account" in caller
except Exception as error:
self._plugin_log(
f"Cannot reach the bucket {options.bucket_name}: {error}",
level=logging.WARNING,
append_to_stream="stderr",
)
return self.dependencies_satisfied
def report(self, model: AshAggregatedResults) -> str | None:
options = self.config.options
stamp = model.metadata.generated_at or datetime.now(timezone.utc).isoformat(
timespec="seconds"
)
key = f"{options.key_prefix}ash-report-{stamp}.json"
body = json.dumps(model.to_simple_dict(), default=str, indent=2)
try:
session = boto3.Session(
profile_name=options.aws_profile, region_name=options.aws_region
)
session.client("s3").put_object(
Bucket=options.bucket_name,
Key=key,
Body=body,
ContentType="application/json",
)
except Exception as error:
self._plugin_log(
f"Error uploading to S3: {error}",
level=logging.ERROR,
append_to_stream="stderr",
)
return None
url = f"s3://{options.bucket_name}/{key}"
ASH_LOGGER.info(f"Uploaded the report to {url}")
return url
validate_plugin_dependencies() returns False when the region or bucket is missing, or when an AWS call fails, and ASH skips the reporter. report() returns None when the upload fails, so ASH logs an error and writes no file (What ASH does with the return value). Each failure is also logged with _plugin_log, which takes a number from logging as its level, such as logging.ERROR. A string such as "ERROR" raises TypeError.
Declare the destination options¶
A reporter that sends findings somewhere, or writes them to a path its options name, declares those options in destination_options, as FindingsBucketReporter does. List every option that chooses where findings are sent or written, or which credentials send them: an endpoint, a region, an account, a bucket, a profile, a token, an output path. Name each as its field in the options model. An alias of the field is covered as well. The default is ().
When the config was built from a file inside the scanned tree (a discovered .ash/.ash.yaml, a --config inside the tree, an extends base, or ASH_CONFIG), or from a file an MCP client delivered, ASH takes these options from the trusted base instead: the defaults, an operator config outside the tree, and --config-overrides. A value such a file set is replaced, under any spelling of the reporter's section that ASH reads as this reporter's, and one warning names each option it ignored. The reporter's other options still apply from the file. ASH does this once it has imported your plugin module and before it builds any reporter, in a scan and in ash report. The built-in AWS reporters declare theirs the same way (Configuration trust model).
A reporter that declares any destination option sends findings somewhere, so whether it runs is the operator's choice too, in both directions. Its enabled comes from the trusted base, or from the default of your config model's enabled when the trusted base sets none: such a file's enabled: true or enabled: false is replaced with a warning where it differs, and leaving the section out leaves the reporter as the trusted base has it. That is why FindingsBucketConfig sets enabled to False. A reporter that declares no destination options writes only local files, and its enabled applies from the file.
Under the MCP server, a delivered file that sets a declared option is scanned with the server's value rather than refused, because the server checks the file against denied_paths before it imports your plugin module. To have such a file refused, add the option to the profile's global_settings.mcp.runtime_overrides.denied_paths, for example /reporters/findings-bucket/options/bucket_name.
A destination that is not an option¶
A destination the reporter reads itself, and not from its options, has to come from a source the scanned repository cannot write, such as an environment variable of the process that runs ASH:
import os
def endpoint_from_operator():
"""The endpoint the operator set for this run, or None when there is none."""
return os.environ.get("ACME_FINDINGS_ENDPOINT") or None
A repository that names an endpoint in its .ash/.ash.yaml then changes nothing. A reporter that makes network calls also runs in the ASH process, outside the scanner sandbox, so it can reach any host ASH can.
Workspace scans¶
In a workspace scan (ash scan --workspace), ASH asks each reporter what to do with several projects through its workspace_behaviour class variable. The default, ReporterWorkspaceBehaviour.PER_PROJECT, writes the report once per project, under each project's own output directory, and no workspace-level file. Set MERGED only if your reporter handles results with one SARIF run per project. Scale to large repositories covers workspace scans.