Suppress findings¶
When a finding is a false positive or an accepted risk, suppress it so it stops failing the scan. A suppression keeps the finding in the results, marked suppressed, with your reason. An ignore path drops findings in a path from the results entirely. Suppressions, ignore paths, and scanner exclusions explains how they differ and how they interact with the gate and the reports.
To write a suppression you need the finding's rule ID and file. Both are in .ash/ash_output/reports/ash.flat.json (rule_id, file_path, line_start) and in the SARIF report ash.sarif (ruleId).
Suppress a finding in the config¶
Add an entry to global_settings.suppressions:
global_settings:
suppressions:
- path: src/deploy.py
rule_id: B602
line_start: 40
line_end: 48
reason: The command is a constant; no user input reaches it
expiration: "2027-01-31"
- path: tests/**
rule_id: B101
reason: assert is how the tests check results
| Field | Required | Meaning |
|---|---|---|
path |
yes | The file, or a pattern, relative to the scanned directory |
reason |
yes | Why the finding is suppressed. It appears in the reports. |
rule_id |
no | The rule to suppress, as ASH reports it. Without it, the entry suppresses every rule in its path. |
line_start, line_end |
no | Lines the finding must overlap, inclusive. line_start alone runs to the end of the file. |
expiration |
no | The date, YYYY-MM-DD, from which the suppression stops matching |
symbol |
no | Only findings inside this function, class, or method; see Scope a suppression to a symbol |
package_name, package_version, package_path |
no | Only findings about this dependency, for dependency scanners such as grype and npm-audit |
- Paths match case-sensitively:
tests/**does not matchTests/. In a pattern without**,*also matches across directory separators, sosrc/*.pymatchessrc/sub/app.py;**matches any depth. A path that starts with./matches nothing, and neither does an empty path,.or./. A trailing slash is literal, unlike in an ignore path:tests/matches no finding, and the scan warns that it matched nothing and givestests/**/*, the pattern for the files under the directory. A bare directory name such astestsnames only the pathtestsitself and matches nothing inside it. When the directory exists in the scanned directory,ash config validateandash config lintwarn about either one with that pattern and exit 0. They warn in the same way, saying what to write instead, about a path that starts with./(./src/app.pyissrc/app.py) and about an empty path or.. A path whose leading names match the tree only in another case (tests/**againstTests/) gets a warning that names the tree's spelling and says that patterns match case-sensitively. - A file whose name is not valid UTF-8 is reported with each such byte written as
\udcNN(a latin-1secr\xe9t.pyassecr\udce9t.py). Apathmatches it written that way, since the\of a\udcNNescape is part of the name and not a separator, or with?for that\(secr?udce9t.py), or by a glob such assecr*t.py. An ignore path matches such a file the same way when ASH lists the files to scan. A UTF-8 file name that spells the same\udcNNtext reads as the same name, so in a tree that holds both files, both are reported under that name, and a path that matches one matches both. rule_idis a case-insensitive pattern with no scanner prefix. It matches any scanner that reports that rule ID.- Quote the
expirationdate. YAML reads an unquoted2027-01-31as a date, not a string, and ASH then refuses the whole config (exit 3).ash config lintandash config getname the problem. - A key that is not in the table makes the config invalid (exit 3), and when a known key is close, the error names it:
ruleIdfails withUnknown key 'ruleId' (did you mean 'rule_id'?). The same holds for an ignore path.
To try a suppression before you write it into the file, pass it for one run. In a repository where detect-secrets reports SECRET-SECRET-KEYWORD in app.py, this run passes:
ash --source-dir . --scanners detect-secrets --offline --no-progress \
--config-overrides 'global_settings.suppressions+=[{"path": "app.py", "rule_id": "SECRET-SECRET-KEYWORD", "reason": "Fixture value, not a real password"}]'
With rule_id misspelled, the same run is refused instead of suppressing every rule in app.py:
ash --source-dir . --scanners detect-secrets --offline --no-progress \
--config-overrides 'global_settings.suppressions+=[{"path": "app.py", "ruleId": "SECRET-SECRET-KEYWORD", "reason": "Fixture value, not a real password"}]'
Expiration¶
On its expiration date, by the local clock of the machine that runs the scan (UTC in the container image, which sets TZ=UTC), a suppression stops matching, and the finding fails the next scan again. At the start of a scan, ASH logs one warning that lists every suppression and ignore path that has expired, including those whose date is today, and then a warning for the ignore paths and one for the suppressions that expire within the next 30 days. With --ignore-suppressions, these warnings leave suppressions out. ash config lint reports suppressions that have expired, and ash config lint --fix removes them.
An expired suppression no longer applies, so this run fails on the finding and names the entry:
ash --source-dir . --scanners detect-secrets --offline --no-progress \
--config-overrides 'global_settings.suppressions+=[{"path": "app.py", "rule_id": "SECRET-SECRET-KEYWORD", "reason": "Fixture value, not a real password", "expiration": "2020-01-01"}]'
The following ignore paths and suppressions have expired and no longer apply:
- Suppression of rule 'SECRET-SECRET-KEYWORD' for 'app.py' expired on 2020-01-01. Reason: Fixture value, not a real password
Remove them, or move their expiration dates, in your ASH configuration file.
Scope a suppression to a symbol¶
symbol limits a suppression to findings inside one definition in the file, named with dots from the top of the file, such as Deployer.run_hook. The name is exact and case-sensitive. A suppression with symbol keeps matching when the code around the definition moves, where a line range would not.
global_settings:
suppressions:
- path: src/deploy.py
rule_id: B602
symbol: Deployer.run_hook
reason: run_hook only runs commands from the deploy manifest
ASH finds the definition by parsing the file with tree-sitter, for Python, JavaScript, TypeScript, and Java files. The parsers are in the symbols extra, which is not installed by default; without it, a suppression with symbol matches nothing and the scan logs a warning that names the extra. The container image includes it.
Installing the extra¶
When the extra is missing, the scan warning and ash config lint print a command that installs the extra's packages. The command is read from the metadata of the ASH you run, so it fits that ASH whatever its version or source. It runs the uv that ASH depends on through the Python interpreter that runs ASH and installs into that interpreter's environment, so it reaches a virtualenv, a uv tool install or pipx install environment, and a checkout's uv sync environment alike. The hint prints the interpreter's full path where this shows <python>:
<python> -m uv pip install --python <python> 'tree-sitter>=0.26,<0.27' 'tree-sitter-java>=0.23.5,<0.24' 'tree-sitter-javascript>=0.25,<0.26' 'tree-sitter-python>=0.25,<0.26' 'tree-sitter-typescript>=0.23.2,<0.24'
A plain pip install of those packages does not do the same: environments made by uv have no pip, so pip runs whichever pip comes next on PATH, usually another Python's.
You can also reinstall ASH with [symbols] added to the source you installed it from. For uvx that is the only way that lasts: uvx rebuilds its cached environment from its own arguments, so an extra installed into it is dropped.
The symbols extra is available from the next release, the first after v3.7.1. v3.7.1 and earlier define only cdk, and asking one of them for [symbols] installs it without the extra, with only a warning. Until that release, install from the main branch, as Install from main shows, with [symbols] after the package name:
uv tool install --force "automated-security-helper[symbols] @ git+https://github.com/awslabs/automated-security-helper.git@main"
main moves, so this command also replaces the ASH you run with the newest code. To stay on one version, or to use a checkout, put that source after the first @ instead: a commit SHA from main, or file:///path/to/checkout. Keep the @ and the source. ASH is not published on PyPI, and the PyPI project with ASH's name is unrelated and has no symbols extra, so asking for the extra by the bare name installs that project instead.
Suppress a finding in the source¶
An ash-ignore comment on the finding's line suppresses it, and ash-ignore-next-line suppresses a finding on the line below:
import subprocess
DEPLOY_COMMAND = "make deploy"
subprocess.call(DEPLOY_COMMAND, shell=True) # ash-ignore: B602 fixed command
# ash-ignore-next-line: B108 the build container's own scratch directory
SCRATCH_DIR = "/tmp/ash-build"
- Write the comment with
#or//. Other comment styles, such as/* */and<!-- -->, are not recognized. - Name exactly one rule ID, as ASH reports it, for example
B602,SECRET-SECRET-KEYWORD, orzizmor/unpinned-uses. It is compared without case but is not a pattern. A comment without a rule ID suppresses nothing. - The comment must be on the line where the finding starts (or the line above it, for
ash-ignore-next-line). Scanners report different start lines for the same code; checkline_startinash.flat.json. - The text after the rule ID is the reason. It is optional, and the reports show it after
(ASH inline).
The scanners' own comments¶
Most scanners have their own inline syntax. ASH runs bandit, semgrep, opengrep, checkov, cdk-nag, cfn-nag, and detect-secrets in a way that honors it. For cfn-guard, cfn-lint, gitleaks, and zizmor, ASH turns the tools' own ignore comments off unless an operator source turns them on for that scanner, so a repository cannot hide its own findings that way; use ASH's suppressions instead:
| Scanner | Comment | In ASH's results |
|---|---|---|
| bandit | # nosec, # nosec B602 |
Not reported. Set scanners.bandit.options.ignore_nosec: true to make bandit ignore these comments. |
| semgrep, opengrep | # nosemgrep, # nosemgrep: <rule-id> |
Reported as suppressed |
| checkov | # checkov:skip=CKV_AWS_18:reason, or checkov: skip: in a CloudFormation resource's Metadata |
Reported as suppressed, with checkov's reason |
| cdk-nag | cdk_nag: rules_to_suppress: in a resource's Metadata |
Reported as suppressed, with the reason |
| cfn-guard | guard: SuppressedRules: in a resource's Metadata |
Reported, unless an operator turns the comments on; then not reported |
| cfn-lint | cfn-lint: config: ignore_checks: in a resource's Metadata |
Reported, unless an operator turns the comments on; then not reported |
| cfn-nag | cfn_nag: rules_to_suppress: in a resource's Metadata |
Not reported |
| detect-secrets | pragma: allowlist secret, pragma: allowlist nextline secret |
Not reported. Setting scan_settings.filters_used replaces detect-secrets' default filters and turns these comments off. |
| gitleaks | gitleaks:allow |
Reported, unless an operator turns the comments on; then not reported |
| zizmor | # zizmor: ignore[<rule>] |
Reported, unless an operator turns the comments on; then not reported |
A finding that is not reported never reaches ASH, so it leaves no trace in any report and --ignore-suppressions cannot bring it back.
Ignore a path¶
An ignore path drops every finding located in a path:
global_settings:
ignore_paths:
- path: vendor/**
reason: Third-party code, scanned upstream
- path: tests/fixtures/**
reason: Deliberately vulnerable test inputs
path and reason are required, and paths match the way suppression paths do. The findings an ignore path drops include a dependency scanner's findings about a package installed under it: a node_modules/ ignore path also drops grype's findings located in node_modules/<package>/package.json.
An ignore path can also have an expiration, which works the way a suppression's does: on that date the ignore path stops applying, so the files it covered are scanned again and their findings count on the next scan. The same holds for the entries of bandit excluded_paths, checkov skip_path, and syft exclude. This expired ignore path no longer drops the finding in app.py:
ash --source-dir . --scanners detect-secrets --offline --no-progress \
--config-overrides 'global_settings.ignore_paths+=[{"path": "app.py", "reason": "Fixture value, not a real password", "expiration": "2020-01-01"}]'
A trailing slash names the files under a directory. With a copy of app.py in vendor/, the ignore path vendor/ drops the copy's finding, and the suppression from the earlier example covers the original, so the run passes:
mkdir -p vendor && cp app.py vendor/app.py
ash --source-dir . --scanners detect-secrets --offline --no-progress \
--config-overrides 'global_settings.ignore_paths+=[{"path": "vendor/", "reason": "Third-party code"}]' \
--config-overrides 'global_settings.suppressions+=[{"path": "app.py", "rule_id": "SECRET-SECRET-KEYWORD", "reason": "Fixture value, not a real password"}]'
rm -r vendor
ASH does not read a .ashignore file. It does apply the repository's .gitignore and .ignore files to the files it hands several scanners; see Coverage. Several scanners also have their own exclusion options, such as bandit's excluded_paths, checkov's skip_path, and semgrep's exclude; the scanner reference lists them.
Find suppressions that no longer match¶
The unused-suppressions reporter, on by default, writes .ash/ash_output/reports/ash.unused-suppressions.json and ash.unused-suppressions.md. They list each entry in global_settings.suppressions that matched no finding in the scan, including expired ones. They do not cover ignore paths or inline suppressions.
After a scan, ash config lint --fix-unused comments out the entries that the report lists, with the date. When it comments out every entry, it leaves suppressions: [] above them, so the file still loads; remove the [] when you turn an entry back on. A suppression can look unused in one environment and still be needed in another, for example when a scanner whose finding it covers is not installed locally but runs in CI. Check the report before you remove entries.
Add a suppression while you review findings¶
ash inspect findings opens the last scan's findings in your terminal. Select a finding and press x to suppress it: ASH asks for a reason and an optional expiration, and adds an entry with the finding's path and rule ID, and its line as line_start and line_end, to the config file (the --config file, else the config ASH discovers, else a new .ashrc.yaml in the current directory). It keeps the comments of a YAML file, except one whose global_settings is written inline (global_settings: {...}), which it rewrites in full, and it writes a JSON config back as JSON. If the file already has an identical entry, nothing is added, and the status bar says so; an entry for another line, reason, or expiration with the same rule ID and path is added. It does not edit TOML files, or a file that uses extends but has no suppressions list of its own.
See every finding¶
--ignore-suppressions reports findings as if no suppression existed, so you can review what the suppressions hide. With the suppression from the earlier example, the finding fails the scan again:
ash --source-dir . --scanners detect-secrets --offline --no-progress --ignore-suppressions \
--config-overrides 'global_settings.suppressions+=[{"path": "app.py", "rule_id": "SECRET-SECRET-KEYWORD", "reason": "Fixture value, not a real password"}]'
It lifts config suppressions, ignore paths, ash-ignore comments, and cdk-nag's Metadata suppressions. Every scanner reads the files under an ignore path, and their findings are reported; to drop only the ignore paths for one run, pass --config-overrides 'global_settings.ignore_paths=[]' instead. It does not bring back what a scanner never reported (findings hidden by the scanner's own comments or its exclusion options), and findings that semgrep and checkov report as suppressed stay suppressed. The repository's .gitignore and .ignore files still apply, and findings in ASH's own output directory stay out of the results. In container mode the flag reaches the scan inside the container and works there as in a local scan.
When an MCP client delivers the config, its suppressions, ignore paths, and scanner exclusions are marked client-supplied in the results; see Configuration trust model. ASH applies all of them itself, so in a local scan with --ignore-suppressions none of them applies, and the findings they would have marked are ordinary findings.