Python API Reference
automated_security_helper.interactions.run_ash_scan
ScanOptions
Bases: BaseModel
All parameters for a single run_ash_scan invocation.
Source code in automated_security_helper/interactions/run_ash_scan.py
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 130 131 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 | |
scanner_statuses(results)
(name, status) for every scanner in results, in scanner-name order.
What :func:no_scanner_ran reads, and read through
get_unified_scanner_metrics for the same reason
:func:incomplete_scanners does: that function is what every reporter and the
metrics table already use, so the set-level gate answers from the statuses the
operator was shown rather than from a second, independently-derived read of
results.scanner_results.
Deliberately just the pairs. An earlier form of this shared one pass with
incomplete_scanners by filtering these pairs, which stopped being possible
when that function grew a second arm reading the per-metric target counters --
a shortfall is not visible in a (name, status) pair. Keeping this narrow is
what makes the two gates independently correct; the cost is one extra pass over
the metrics, which _compute_exit_code already takes for the findings count.
Source code in automated_security_helper/interactions/run_ash_scan.py
no_scanner_ran(observed)
True when observed is non-empty and none of its scanners reached a verdict.
Non-empty is load-bearing and is not the same assertion. An empty scanner set
means the scan phase recorded nothing, which is reachable from a legitimate
--phases convert run and is refused at the CI boundary instead (see
assert_scanners_completed.py, which fails a results file reporting no
scanners at all). Folding the two together here would turn a phase-limited run
into an error.
Source code in automated_security_helper/interactions/run_ash_scan.py
incomplete_scanners(results)
(name, status) for every scanner that was selected and did not complete.
"Did not complete" covers two distinct failures, and it did not always cover the second:
- The scanner never produced a result -- status ERROR or MISSING.
- The scanner ran, reported a status, and could not evaluate some of the targets it was given.
Only (1) was selected on, because that is a status test and (2) does not move
a target's status. ScanResultsContainer.determine_status returns ERROR only
once targets_failed >= targets_attempted, so a scanner that lost some of
its input keeps whatever the severity gate gave it and the gate could not see
it. Measured on this repository under its own config: cdk-nag attempts 10
targets and fails 4, its per-target container still reports PASSED, and the
gate exited 0. Two of those four are real CloudFormation templates that went
unscanned.
Do not read "(2) does not change the status" as "nothing in this change
touches a status". A sibling commit ORs any_target_errored into the
error flag, so a target tree that lost ALL of its targets -- which
determine_status does report as ERROR -- now reaches the rolled-up status
where previously only the "source" report was consulted, and even that only
when the scanner was absent from scanner_results. That is a status change,
it is not opted in, and it is the thing the CHANGELOG's "Behavior changes"
entry describes. The two arms are separable: partial loss stays out of the
status and is expressed here; total loss on any tree is a status.
Case (2) is expressed HERE and not as a status, and not by widening
_INCOMPLETE_SCANNER_STATUSES. The reason is a caller rather than taste.
cli.merge._completed keys on status against that same set to answer a
different question -- whether a shard's scanner ran at all -- and
_verify_shard_contributions refuses a merge outright where a shard
completed none of the scanners it owned. A scanner that lost one target of ten
ran, so any status-shaped expression of partial coverage would propagate into
shard refusal and start rejecting healthy shards, failing the merge far from
the code that caused it. That is the concrete cost of adding a
ScannerStatus member for this, and the reason none was added.
Worth being precise about the residual risk, because two earlier versions of
this comment got it wrong in opposite directions. One said _completed
inspects a ScannerTargetStatusInfo, which declares no target counters, so
the protection is structural rather than conventional. That is false, and
measurably so: the model sets extra="allow", so counters written into
scanner_results land in model_extra and a getattr for them
succeeds. Nothing structural stops _completed reading coverage; what stops
it is that it does not, which is a behavior and therefore something a test can
hold. tests/unit/interactions/test_fail_on_partial_target_coverage.py
holds it, and mutating _completed to consult the counters reddens it.
The other claimed no reporter and no summary table sees anything new, and this
change is the reason both do. ScannerMetrics gained targets_attempted
and targets_failed; the console table, the markdown report and
ash.flat.json all carry them, and the first two grew an "Incomplete
coverage" section. Measured on this repository, ash.summary.md gained
### Incomplete coverage and ash.flat.json gained the two keys. What is
genuinely untouched is narrower and worth naming exactly: _completed, and
the DEFAULT exit code, which reaches this function only once
_resolve_fail_on_incomplete_scanners returns true.
tests/unit/cli/test_merge.py pins the boundary from the merge side.
Precedence between the two arms is on TOTALITY, not on status. Total loss
satisfies the coverage condition too -- failed >= attempted implies
failed > 0 -- and appending counts there would give an ERROR row a
parenthetical it never had while saying nothing the status does not already
say, so total loss reports the bare status.
A PARTIAL shortfall reports its counts whatever the status is, and that is a
correction rather than a preference. Selecting the bare-status arm on status
alone made this function unable to deliver what it exists for in the case it
was written for: an ERROR scanner that is only partly incomplete took the bare
arm and printed cdk-nag: ERROR, never cdk-nag: ERROR (4 of 10 targets
unevaluated), so the counts the operator needs to tell "the tool is absent"
from "the tool ran and skipped four templates" were dropped by the routing.
An ERROR with no counters available still falls to the bare form, because there
is no honest denominator to print.
Read through get_unified_scanner_metrics rather than off
results.scanner_results directly, so the gate and the report cannot
disagree: that function is what every reporter and the metrics table already
use, and it is where excluded-versus-missing precedence is decided. The target
counters are read from the same rows for the same reason -- ScannerMetrics
is what the summary table prints, so the gate fails on exactly the numbers the
operator was shown rather than on a second, independently-derived count.
An allowlist narrowing -- --scanners bandit -- does not trip this, because
the scanners it leaves out are recorded SKIPPED. That was not always true: the
scan phase used to validate a scanner's dependencies before checking whether it
had been selected, so on a host without cfn-nag, grype and syft a
--scanners bandit run reported those three MISSING while the six
tool-present scanners it left out reported SKIPPED. Which status an unselected
scanner got therefore depended on whether its tool happened to be installed.
See core/phases/scan_phase.py for the ordering that fixed it.
Filtering here against opts.scanners was the alternative and is rejected:
it would make the exit code disagree with the status the report prints for the
same scanner, and it has no counterpart in ash merge, which has no scanner
selection to consult. Fixing the recorded status instead makes both agree.
Tested against _COMPLETE_SCANNER_STATUSES and not against
_INCOMPLETE_SCANNER_STATUSES, though the two are complements over the enum.
metric.status is a plain string that may have come from a results file this
version did not write, and only the allowlist form treats a status outside the
enum entirely as incomplete rather than as a scanner that ran.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
results
|
Optional[AshAggregatedResults]
|
The aggregated results, or None when the scan produced none. |
required |
Returns:
| Type | Description |
|---|---|
List[tuple[str, str]]
|
Pairs in scanner-name order, empty when every selected scanner completed. |
List[tuple[str, str]]
|
The second element is the scanner's own status for a status-based |
List[tuple[str, str]]
|
incompleteness, and that status followed by the unevaluated-target counts |
List[tuple[str, str]]
|
for a coverage-based one. It is a display string, not a status token: |
List[tuple[str, str]]
|
both callers interpolate it into a message and neither parses it. |
Source code in automated_security_helper/interactions/run_ash_scan.py
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 | |
unevaluated_rules(results)
Every rule a scanner reported, at run level, that it could not evaluate.
WHY THIS IS A SECOND FUNCTION RATHER THAN PART OF incomplete_scanners
incomplete_scanners answers "which scanners lost whole targets", and it
answers it from the target counters. A rule that raised mid-evaluation loses
neither a scanner nor a target: the scanner ran, the target was read, most of
the rules reached a verdict, and one did not. Counting it as a lost target
would overstate in a way that is measurable rather than theoretical --
ScanResultsContainer.determine_status returns ERROR once
targets_failed >= targets_attempted, so a rule that raises on every
template (which is the normal case for one that cannot resolve an intrinsic)
would report the scanner as having evaluated nothing, and the cdk-nag
scanner's own log line would read "No rules were evaluated" about a run that
evaluated all but one of them.
So the granularity is the rule, and the fact is read from where SARIF already puts it.
WHAT IT READS, AND WHY THAT IS THE RIGHT CHANNEL
invocation.toolExecutionNotifications is, in the schema's own words, "A
list of runtime conditions detected by the tool during the analysis", and
notification.associatedRule is "A reference used to locate the rule
descriptor associated with this notification". A rule raising instead of
returning a verdict is a runtime condition, and the rule it happened to is
what to associate it with. The cdk-nag scanner already writes exactly that.
Reading it here rather than inventing a counter is what makes this gate scanner-agnostic: any scanner -- or any externally-produced SARIF that ASH ingests -- which reports an error-level runtime condition is reporting that part of its analysis did not run, and that is the thing being gated on.
ONLY level == "error". warning is the field's default and a tool may
use it for conditions that cost no coverage, so gating on it would fail scans
for notes. The cdk-nag scanner sets error deliberately and says so.
.value RATHER THAN str() ON THE LEVEL. Level is a str-mixin enum,
so Enum.__str__ still wins and str(Level.error) renders
"Level.error", which matches nothing. Comparing the raw member against
"error" works because of the str mixin, but only for a model built
in-process; a model round-tripped through JSON carries a plain string. Both
shapes are handled by taking .value when it is there.
Read from the in-memory model rather than re-reading reports/ash.sarif.
Verified end to end against a real cdk-nag run: the notifications survive
sanitize_sarif_paths, apply_suppressions_to_sarif,
attach_scanner_details, merge_sarif_report, and a second scanner
merging into the same aggregate. Nothing in that chain rewrites them, which
is why no disk read is needed to see them.
SUPPRESSIONS ARE HONORED, AND THAT IS NOT A CONVENIENCE
A notification carries no suppression of its own -- SARIF puts suppressions on
results -- so read naively this gate would be unsuppressable, and an operator
who has already reviewed a rule's failure and accepted not knowing its verdict
would have no way to say so. That is not hypothetical: this repository's own
.ash/.ash.yaml carries fifteen such entries under the heading "rules that
threw and never ran", each with a reviewed reason, and its own note calls
naming them "the only way to keep the exit code honest". A gate that ignored
them would fail ASH's own default scan with no escape hatch, which is a worse
defect than the one being fixed.
So a rule is reported only when at least one of its not-evaluated results is unsuppressed. The results are what suppression applies to, and consulting them is what lets the existing mechanism reach a fact recorded somewhere it cannot be attached.
kind is the filter rather than the cdk-nag property bag, so this stays
generic to SARIF. Restricting to not-evaluated rows matters: one rule can throw
on one resource while reaching a verdict on another, and counting an ordinary
unsuppressed finding as evidence would report a rule whose only failure was
suppressed.
A rule with an error-level notification and NO matching result is reported. Absence of a result is not evidence of suppression, and defaulting to silence there would reintroduce the silent pass through the one shape nothing checks.
Returns:
| Type | Description |
|---|---|
List[str]
|
Rule ids in sorted order, deduplicated, with the notification's message |
List[str]
|
substituted for a notification that names no rule so a condition is never |
List[str]
|
silently dropped for lacking an id. Empty when every rule was evaluated, |
List[str]
|
which is the case for every scanner that reports no such condition at all. |
Source code in automated_security_helper/interactions/run_ash_scan.py
495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 | |
build_project_scan_settings(opts)
Build the per-project settings record a workspace run scans from.
Module-level and public because there are two callers, not one: the CLI's
_run_workspace_mode and the MCP surface in
automated_security_helper.cli.mcp.workspace. They assemble their
ScanOptions differently -- one from typer arguments, one from MCP tool
parameters -- but the record handed to execute_workspace has to come from
one construction.
Why it is extracted rather than written twice
ProjectScanSettings has 24 fields and every one of them is optional with a
plausible default, so a second construction that omits a field produces a
valid record and a scan that runs to completion with a setting the caller
never chose. Nothing raises. The two worst omissions are config_overrides,
where dropping it silently scans with different configuration, and
ignore_suppressions, where the default is the lenient direction.
What it owns, and why the boundary is here
Both derived inputs are computed inside: the workspace execution config, via
:func:_resolve_workspace_execution_config, which supplies
max_parallel_projects and project_timeout; and the phases list,
which is the only field with branching behind it. A builder that took either
as an argument would push part of the construction back out to its callers,
which is where the duplication started.
Note what it does not own. Setting ASH_OFFLINE stays with the caller:
it mutates process state and has to be unset in a finally, which a
builder returning a value cannot do.
Failure modes
An unreadable ASH config at the workspace root does not raise here.
_resolve_workspace_execution_config warns and falls back to the defaults,
because these are scheduling knobs -- refusing the whole scan over a typo in
one would be a poor trade, and on the MCP path it would surface as an
internal error for what is really an operator's config file.
Source code in automated_security_helper/interactions/run_ash_scan.py
1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 | |
run_ash_scan(source_dir=None, output_dir=None, config=None, config_overrides=None, offline=False, strategy=ExecutionStrategy.PARALLEL, scanners=None, exclude_scanners=None, progress=True, output_formats=None, cleanup=False, phases=None, inspect=False, existing_results=None, python_based_plugins_only=False, quiet=False, simple=False, verbose=False, debug=False, color=True, fail_on_findings=None, fail_on_incomplete_scanners=None, ignore_suppressions=False, min_severity='low', changed_files_only=False, base_ref='origin/main', shard_index=None, shard_count=None, mode=RunMode.local, show_summary=True, log_level=AshLogLevel.INFO, build=True, run=True, force=False, oci_runner=None, build_target=None, offline_semgrep_rulesets='p/ci', container_uid=None, container_gid=None, ash_revision_to_install=None, custom_containerfile=None, custom_build_arg=None, ash_plugin_modules=None, container_network='bridge', workspace_plan=None, allow_missing_projects=False, *args, **kwargs)
Run an ASH scan against source_dir, outputting results to output_dir.
When workspace_plan is given, source_dir is the workspace root and each
project in the plan is scanned in its own scope. See
:mod:automated_security_helper.workspace.execution.
Source code in automated_security_helper/interactions/run_ash_scan.py
1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 | |
automated_security_helper.models.core
Core models for security findings.
IgnorePathWithReason
Bases: BaseModel
Represents a path exclusion entry.
Source code in automated_security_helper/models/core.py
matches_path(file_path)
Return True if file_path matches this entry's path pattern.
Supports exact matches, simple globs (*.py), and recursive globs
(tests/**/*.py). Matching is case-insensitive for OS portability.
Source code in automated_security_helper/models/core.py
ToolArgs
Bases: BaseModel
Base class for tool argument dictionaries.
Source code in automated_security_helper/models/core.py
AshSuppression
Bases: IgnorePathWithReason
Represents a finding suppression rule.
Source code in automated_security_helper/models/core.py
58 59 60 61 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 130 131 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 | |
id
property
Stable identifier derived from path|rule_id|line_start|line_end.
Unspecified rule_id is rendered as *. When line_end is None,
line_start is reused to match how suppressions are indexed elsewhere
in the codebase.
is_expired
property
Return True if this suppression has a past expiration date.
days_until_expiry
property
Days from today until expiration; None if no expiration is set.
A negative value indicates the suppression has already expired.
validate_line_range(v, values)
classmethod
Validate that line_end is greater than or equal to line_start if both are provided.
Source code in automated_security_helper/models/core.py
validate_expiration_date(v)
classmethod
Validate that expiration date is in YYYY-MM-DD format.
Past dates are accepted; use is_expired to check whether the suppression has expired at runtime.
Source code in automated_security_helper/models/core.py
matches(finding)
Return True if finding is covered by this suppression rule.
Checks rule_id (exact or glob), path (supports **), and optional
line range overlap. Expired suppressions never match.