Output files¶
What a scan writes to its output directory. The output directory is
<source-dir>/.ash/ash_output unless you pass --output-dir; see
Read scan results for which file to open for what.
Layout¶
A scan of a project with Python, a CloudFormation template, and a GitHub Actions workflow, with every built-in scanner installed:
.ash/ash_output/
├── ash.log
├── ash_aggregated_results.json
├── ash-ignore-report.txt
├── ash-scan-set-files-list.txt
├── analysis/
├── converted/
├── reports/
│ ├── ash.cdx.json
│ ├── ash.csv
│ ├── ash.flat.json
│ ├── ash.ghas.sarif
│ ├── ash.gl-dependency-scanning-report.cdx.json
│ ├── ash.gl-sast-report.json
│ ├── ash.html
│ ├── ash.junit.xml
│ ├── ash.ocsf.json
│ ├── ash.sarif
│ ├── ash.summary.md
│ ├── ash.summary.txt
│ ├── ash.unused-suppressions.json
│ └── ash.unused-suppressions.md
└── scanners/
├── bandit/
│ └── source/
│ ├── ASH.ScanResults.json
│ ├── BanditScanner.stderr.log
│ └── bandit.sarif
├── checkov/
│ └── source/
│ ├── ASH.ScanResults.json
│ ├── CheckovScanner.stdout.log
│ ├── results_cyclonedx.json
│ └── results_sarif.sarif
└── ...one directory for every scanner
| Path | Written by | Contents |
|---|---|---|
ash.log |
ASH's logger | The run's log, INFO and above by default, one tab-separated line per record: time, level, source location, and message. Lines from a scanner have a field before the message naming the scanner and target, such as ([yellow]bandit[/yellow] @ [magenta]source[/magenta]), and messages can contain the same Rich markup. |
ash_aggregated_results.json |
the scan engine | Every result of the scan in one document. See below. |
ash-ignore-report.txt |
the scan set step | The ignore rules that shaped the scan set: the text of each .gitignore and .ignore ASH found, and ASH's built-in rules (.git and **/cdk.out/asset.* left out, **/*.template.json kept). |
ash-scan-set-files-list.txt |
the scan set step | The files ASH selected, one absolute path per line. Scanners that walk the source directory themselves can read more; see Coverage. |
analysis/ |
ash scan --inspect |
The SARIF field analysis. Empty otherwise. |
converted/ |
the converters | archive/ holds a directory per .zip, .tar, or .gz file with the members ASH extracted; jupyter/ holds each notebook converted to a script, named after the notebook's path and ending in -converted.py for a Python notebook (see Converted files). Empty when there was nothing to convert. |
reports/ |
the reporters | One file per enabled reporter, named ash.<extension>. See below. |
scanners/<name>/<target>/ |
each scanner | ASH.ScanResults.json and the tool's own output for one target: source for the source directory, converted for converted/. See below. |
In workspace mode the workspace's output directory holds the combined
ash_aggregated_results.json, and each project's own output tree is under
projects/<project>/. The workspace's reports/ holds the reports that combine every
project, and workspace-reports.json, which lists where each project's own reports are.
The reporters that write one file per project, cyclonedx, spdx, github-ghas,
gitlab-sast, and gitlab-cyclonedx, write nothing at the workspace level; their files
are in each project's reports/. See
Scale to large repositories.
Each scan replaces the last¶
Before a scan writes anything, ASH removes and recreates analysis/, reports/,
scanners/, and converted/, and deletes ash_aggregated_results.json,
ash-ignore-report.txt, and ash-scan-set-files-list.txt. ash.log is truncated
when the logger opens it.
So a scan that fails leaves only what it wrote before it failed: gate CI on ASH's exit
code, not on whether reports/ exists. Container mode and Nix mode delete the same
entries before they start the scan.
In local mode, ASH clears the output directory only after it has accepted the command
line and the configuration. A scan refused before that leaves the earlier results in
place: an unknown option or scanner name, or a selection that excludes every scanner it
names (exit 5), or a configuration that does not validate (exit 3). Only ash.log is
rewritten. For example, after a scan of the sample project:
a scan that names a scanner ASH does not have is refused:
and the first scan's results are still there:
A scan started over MCP keeps the previous results until it has its own. When it
starts, the server moves the previous reports/, scanners/, analysis/,
converted/, ash_aggregated_results.json, ash-ignore-report.txt, and
ash-scan-set-files-list.txt into .ash_previous_results/ in the output directory.
When the scan finishes with results, they are deleted. When it fails or is canceled,
the server removes what the scan wrote, puts them back, and writes
.ash_previous_results_restored beside them. The next scan removes that file, and the
next ash scan also deletes a held .ash_previous_results/. For a scan whose previous
results were put back, get_scan_progress returns output_holds_previous_results: true,
and the tools that read results from the directory, such as get_scan_results, answer
that the scan is not complete.
ash scan --use-existing runs only the report phase: it writes reports/ again from the
ash_aggregated_results.json already in the output directory, rewrites
metadata.summary_stats in that file, leaves scanners/ and the other files in place,
and exits with the verdict of the stored results.
Links are refused¶
ASH refuses to write through a symbolic link or a Windows directory junction at any of these:
- the output directory itself, or a directory on the way to it below the source
directory (the default
.ash/ash_outputis below it, so a repository can commit such a link); analysis,reports,scanners, orconvertedin the output directory;- the files ASH writes there by a fixed name:
ash_aggregated_results.json,ash-ignore-report.txt,ash-scan-set-files-list.txt,ash.log, andash.log.jsonl; - in workspace mode,
projectsand.workspace-spoolin the workspace's output directory, and each project'sprojects/<project>directory.
It also refuses an output directory below the source directory whose real path, with every link resolved, is outside the source directory.
The check runs before anything is written or deleted, in every run mode and whatever
the sandbox mode, and also in ash report and ash merge. The command exits 3:
ERROR (3) The output directory /work/out-link is a symlink. ASH does not write to, clear or
mount its output directory through a link, whatever the sandbox mode. Pass --output-dir with
a directory that is not a link, or replace the link with a directory.
In an ash --workspace scan, a link at projects or .workspace-spool stops the whole
run this way before any project starts. A link at projects/<project> fails only that
project, logged as Project '<project>' was not scanned: and the same refusal. The other
projects still run, and the run exits 1, or 2 when another project has actionable
findings (see Workspace mode). Over MCP,
run_ash_workspace_scan refuses the whole call when any of these is a link.
Directories above an output directory that is outside the source directory are not
examined, so --output-dir /tmp/ash works on a system where /tmp is a link.
Reports are checked again before any reporter writes. If reports/, or a file that one
of the reporters about to run writes there, such as reports/ash.sarif, is a link, the
run stops with exit 1:
ERROR (1) /work/out/reports/ash.sarif is a symlink. ASH does not write a report through a link.
Remove the link and run ASH again.
This applies to scans in every run mode, including --use-existing and workspace scans,
and to ash merge. ash merge prints the same text after Refusing to write reports:.
ash report prints its report instead of writing it, and refuses a link only at a file
the reporter writes beside it.
ash_aggregated_results.json¶
The scan engine writes this file, not a reporter, and every reporter and ash report
reads it. Its JSON schema is
AshAggregatedResults.json.
| Top-level name | Contents |
|---|---|
name, description |
A label with the scan's start time, and a fixed description. |
metadata |
About the run. See below. |
ash_config |
The configuration the scan resolved, after overrides and trust rules. A value or key a config file read from the environment is recorded as the reference the file wrote, here and in metadata.expected_scanners (Read values from the environment). |
scanner_results |
One row per scanner, keyed by scanner name. See below. |
converter_results |
One row per converter, keyed by converter name. See below. |
sarif |
The aggregated SARIF report. See SARIF. |
cyclonedx |
The CycloneDX software bill of materials syft produced; with no components when syft did not run. |
additional_reports |
Each scanner's per-target record, keyed by scanner and then target (source, converted). The same records as scanners/<name>/<target>/ASH.ScanResults.json. |
validation_checkpoints |
The checks ASH ran on its own execution (were all selected scanners queued, did each finish), and configuration warnings, which have type set to config_warning. |
used_suppressions |
The ids of the global_settings.suppressions entries that matched a finding (format below). Inline ash-ignore comments are not listed. |
workspace |
The workspace plan and per-project outcomes in workspace mode; null otherwise. |
A suppression's ID is its path, rule ID, first line, and last line joined with |, with
* for a part the entry does not set, so an entry for rule B602 in app.py with no
lines is app.py|B602|*|*. Entries that set package or symbol fields have more parts.
metadata holds report_id, generated_at, project_name (the config's
project_name, else the ASH_PROJECT_NAME environment variable, else ash-scan),
tool_version (the ASH version), summary_stats, validation_summary,
execution_discrepancy_report, shard (set by --shard-index), expected_scanners,
requested_scanners_not_run (the names in --scanners or ASH_SCANNERS that matched no
scanner, or null when every name matched or no list was given; ash merge records the
names of all the shards), plugin_load_errors (plugin groups that failed to import), and
changed_files_only
(true when --changed-files-only filtered the findings, false on a full scan,
including one that fell back to a full scan).
report_id is unique to each scan: ASH-, the scan's start as a UTC date and time, and
12 random hexadecimal digits, such as ASH-20261011180835-deff7bedfe39. Reports rendered
again from saved results, by ash report or --use-existing, keep it. ash merge gives
the merged results an id of the same shape, computed from the shards' ids, so merging the
same shards again gives the same id. A workspace scan's combined results have an id of
the form ASH-WORKSPACE-<workspace file name>-<date and time>-<hex digits>.
summary_stats totals the scan: severity_counts (suppressed, critical, high,
medium, low, info), total (unsuppressed findings), actionable, the count of
scanners in each status (passed, failed, missing, skipped, error), and
start, end, and duration.
Scanner rows¶
Each scanner_results row has:
| Name | Meaning |
|---|---|
status |
PASSED, FAILED, ERROR, MISSING, or SKIPPED; see the summary table. |
severity_counts, finding_count, actionable_finding_count, suppressed_finding_count |
The counts the summary table shows. |
duration |
How long the scanner ran, in seconds. |
dependencies_satisfied |
false on a MISSING row. |
excluded |
true when the run left the scanner out: --scanners, --exclude-scanners, enabled: false, --python-only, the shard split, or a platform it does not support. |
exit_code |
The tool's exit code, the highest over the targets it ran on, or null when no tool ran: a MISSING or excluded scanner, one that had nothing to scan, or one that started no tool for its targets. |
missing_reason |
On a MISSING row, why: the tool that was not found and where ASH looked, what the scanner's own dependency check reported, that the tool was found only inside the scanned directory, which ASH does not run unless you allow it (see Tools inside the scanned directory), or that the scanner could not be sandboxed. null otherwise. |
skipped_reason |
On a SKIPPED row, why: the option that left it out, such as not named by --scanners, or what it had nothing to scan, such as no CloudFormation template in the scanned files. null otherwise. |
{
"grype": {
"status": "MISSING",
"dependencies_satisfied": false,
"excluded": false,
"severity_counts": {"suppressed": 0, "critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0},
"finding_count": 0,
"actionable_finding_count": 0,
"suppressed_finding_count": 0,
"exit_code": null,
"duration": 0.0,
"missing_reason": "its tool grype was not found on PATH or in /home/me/.ash/bin, /home/me/.local/share/uv/tools/automated-security-helper/bin, /usr/local/bin; install it with `ash dependencies install` or exclude grype with --exclude-scanners",
"skipped_reason": null
}
}
Converter rows¶
Each converter_results row (archive, jupyter) has:
| Name | Meaning |
|---|---|
dependencies_satisfied |
false when the converter's tool was missing. |
excluded |
true when the converter was turned off. |
failure |
The error, as <Type>: <message>, when the converter itself crashed or could not be created, or a note that it was dropped although its dependency check passed. |
candidate_inputs |
How many inputs it set out to convert, when it counts them. |
refused_inputs |
Inputs it declined to read, each with path, member (for an archive member) and reason, such as links, special files, and archive members with absolute names or names that would land outside the extraction directory. These never fail the scan. |
failed_inputs |
Inputs it tried and could not convert, each with path and reason, such as a corrupt archive or a notebook nbconvert could not read. A reason that comes from an unexpected error is the error's type and message, such as BadZipFile: Bad CRC-32 for file 'app.py'. |
converted_paths |
The files it wrote under converted/. |
A converter with a failure, any failed_inputs, or a missing tool while it had inputs
makes the conversion incomplete. Under the default fail_on_incomplete_scanners the scan
then exits 1, because the scanners were given fewer files than the repository has. The
console line for each such converter, and its entry in incomplete_converters in the MCP
scan results, give its failure, the number of failed inputs and up to five of their
paths, or dependencies unavailable, so it never ran.
{
"archive": {
"dependencies_satisfied": true,
"excluded": false,
"failure": null,
"candidate_inputs": null,
"refused_inputs": [],
"failed_inputs": [
{"path": "bundle.zip", "reason": "it is not a zip or tar archive Python can read"}
],
"converted_paths": []
}
}
Suppressions and ignore paths in ash_config¶
Each entry under ash_config.global_settings.suppressions and
ash_config.global_settings.ignore_paths has a client_supplied flag. ASH sets it to
true on entries that came from the config file of a tree an MCP client delivered, and
marks the findings they hide in the SARIF (below). Treat the flag as a label for reading
results, not as an access control. See
Configuration trust model.
SARIF¶
reports/ash.sarif and the sarif member of ash_aggregated_results.json hold the same
SARIF 2.1.0 run, written two ways. The member writes every field of every result,
defaults included, so its results show "ruleIndex": -1, and "rule": null unless the
scanner gave one. reports/ash.sarif leaves out the fields ASH did not set, ruleIndex
among them, and the empty ones, and adds each result's rule reference (below). After a
scan of the sample project:
import json
with open(".ash/ash_output/ash_aggregated_results.json", encoding="utf-8") as f:
member = json.load(f)["sarif"]["runs"][0]["results"][0]
with open(".ash/ash_output/reports/ash.sarif", encoding="utf-8") as f:
report = json.load(f)["runs"][0]["results"][0]
for name, result in (("sarif member", member), ("reports/ash.sarif", report)):
print(name, result.get("ruleIndex", "no ruleIndex"), result.get("rule", "no rule"))
The run:
tool.driveris ASH itself (AWS Labs - Automated Security Helper, with ASH's version).tool.extensionshas one entry per scanner that produced SARIF, named after the scanner, carrying that scanner's rules and, asversion, the version the scanner's own run reported. Some extensions also carrysemanticVersion. When ASH does not know a scanner's version,versionis left out. A scanner that produces only an SBOM, such as syft, has no entry.resultsholds every scanner's results, appended without merging duplicates. Every result has alevel; ASH fills in the level of a result that stated none, as How ASH counts findings describes.- In
reports/ash.sarif, a result whose rule its scanner's extension defines carriesrule, as SARIF asks for rules defined in an extension:rule.toolComponentgives the extension'snameand itsindexintool.extensions, andrule.indexis the rule's position in that extension'srules. A result whose rule no extension defines, such as a detect-secrets result, has norule. invocationshas one entry per scanner run, with its command line, working directory, exit code, and times. The command lines contain absolute paths from the machine that ran the scan.
Each result's properties carries scanner_name, tags, scanner_details
(tool_name, tool_version, and the run's start and end times), and issue_severity
when the scanner integration assigned one. scanner_version and
scanner_details.tool_version hold the scanner's own version, and are left out when
ASH does not know it. Locations in the source directory are relative to it, wherever
ASH runs from. Findings in converted/ point into the output directory.
{
"ruleId": "B602",
"rule": {"id": "B602", "index": 2, "toolComponent": {"name": "bandit", "index": 0}},
"level": "error",
"message": {"text": "subprocess call with shell=True identified, security issue."},
"locations": [
{"physicalLocation": {"artifactLocation": {"uri": "app.py"}, "region": {"startLine": 7, "endLine": 7}}}
],
"suppressions": [
{
"kind": "inSource",
"justification": "(ASH) Suppressing finding for rule 'B602' in 'app.py' with reason: Commands come from a fixed allowlist in this demo"
}
],
"properties": {
"tags": ["bandit"],
"issue_confidence": "HIGH",
"issue_severity": "HIGH",
"scanner_name": "bandit",
"scanner_version": "bandit 1.9.4",
"scanner_details": {
"tool_name": "bandit",
"tool_version": "bandit 1.9.4",
"tool_invocation": {
"start_time": "2026-10-11T18:11:00.025599+00:00",
"end_time": "2026-10-11T18:11:00.954843+00:00"
}
}
}
}
A suppressed finding stays in results with a suppressions entry:
| Source of the suppression | kind |
justification starts with |
|---|---|---|
A global_settings.suppressions entry |
inSource |
(ASH) Suppressing finding for rule ... |
An inline ash-ignore comment |
inSource |
(ASH inline) |
| An entry from the config of a tree an MCP client delivered | inSource |
(ASH, client-supplied config) |
| An ignore path or scanner exclusion from such a config | external |
(ASH, client-supplied config) |
Client-supplied entries also carry properties.clientSupplied: true. A finding under
one of your own global_settings.ignore_paths is removed from the results, not
suppressed. See Suppressions, ignore paths, and scanner exclusions.
In workspace mode the document has one run per project. Each run declares its
project's root in originalUriBaseIds, keeps its URIs relative to that root, and gives
the workspace-relative path in properties.workspace_uri.
reports/ash.ghas.sarif is a smaller variant for GitHub code scanning; see
github-ghas.
Reports¶
reports/ holds one file per enabled reporter, named ash.<extension>, or ash.txt
for a reporter that declares no extension. The table lists the reporters ASH ships and
whether each is enabled by default; Reporters gives each one's format
and options.
A reporter's extension must be a file name suffix. A configured extension that
contains /, \, .., or a NUL character is ignored with a warning, and the reporter's
default is used. If a reporter's report file name is still not a single file name, one
that is not empty, ., or .. and contains no /, \, or NUL, the run stops with
exit 1 before any reporter writes.
| File | Reporter | Enabled |
|---|---|---|
ash.cdx.json |
cyclonedx |
yes |
ash.csv |
csv |
yes |
ash.flat.json |
flat-json |
yes |
ash.ghas.sarif |
github-ghas |
yes |
ash.gl-dependency-scanning-report.cdx.json |
gitlab-cyclonedx |
yes |
ash.gl-sast-report.json |
gitlab-sast |
yes |
ash.html |
html |
yes |
ash.junit.xml |
junitxml |
yes |
ash.ocsf.json |
ocsf |
yes |
ash.sarif |
sarif |
yes |
ash.summary.md |
markdown |
yes |
ash.summary.txt |
text |
yes |
ash.unused-suppressions.json, ash.unused-suppressions.md |
unused-suppressions |
yes |
ash.spdx.json |
spdx |
no |
ash.yaml |
yaml |
no |
Reporters from plugin modules, such as the AWS reporters, write their files here too. A reporter that fails logs an error and leaves its file out; it does not change the exit code.
ash.flat.json holds metadata (the project name, the scan and report times, and ASH's
version), scanner_metrics (one row per scanner with its counts, status, excluded,
dependencies_missing, missing_reason, and skipped_reason), content_databases,
top_hotspots, and findings, one record per finding. A finding's id is
<scanner>-<rule>-<12 hex digits>, such as bandit-B602-426afbe0ae52, and no two
findings in a scan share one. The digits are a hash of what identifies the finding: the
SARIF fingerprints its scanner gives, when they tell findings apart, or else its file
and its start and end lines, plus the package for a dependency finding and the project
in a workspace scan. A file a converter wrote is identified by its path under
converted/. Findings that share all of that, such as two secrets on one line, are told
apart by their column and message. So a finding keeps its id from one scan to the next,
unless it has no fingerprints and an edit above it moves it to other lines.
Per-scanner results¶
scanners/<name>/ is where each scanner writes, and under the sandbox it is the only
directory a scanner can write to. Each target's directory below it holds the tool's raw
output (SARIF, JSON, <Class>.stdout.log, and <Class>.stderr.log), the configuration
files ASH generated for the tool, and ASH.ScanResults.json, ASH's record of that run.
Some scanners keep the files ASH generated in scanners/<name>/ itself. Every scanner
gets a directory; one that did not run holds only a short ASH.ScanResults.json with
its status.
ASH.ScanResults.json has:
| Name | Meaning |
|---|---|
scanner_name, target, target_type |
Which scanner ran, on which directory, and whether it was source or converted. |
exit_code |
The tool's exit code. |
start_time, end_time, duration |
When it ran. |
finding_count, severity_counts |
How many results the run produced, suppressed ones included, and their split by severity, with suppressed results under suppressed. |
status |
The status of this run. It can differ from the scanner's row in scanner_results, which is the verdict the summary table shows. |
targets_attempted, targets_failed |
For scanners that count their inputs, how many they tried and how many they could not evaluate. |
raw_results |
For a failed run, errors, exception, and stack_trace. |