stackhawk-api-recipes · git:20260422.3b517c7 · 2026-04-22 · sha256 983b57655eec7bc7
stackhawk-api-recipes git:20260422.3b517c7A
Immutable. This exact content is served forever at /api/v1/blob/983b57655eec7bc7.
---
description: >
StackHawk API reporting recipes: org security posture summary, app deep dive (scan -> alerts -> findings), stale apps detection, scan diff (what changed since last scan). Pre-built jq compositions using hawk_api helpers. Prefer hawkop shortcuts (stackhawk-api-hawkop) for the deep-dive chain; use these recipes for the per-env untriaged posture view.
globs:
alwaysApply: false
---
# StackHawk API Reporting Recipes
Pre-built compositions for common security reporting questions. Each recipe uses
the `hawk_api` and `hawk_api_all_pages` helpers from `api-auth.md`. Source that
helper library before running any recipe.
**Setup (run once per shell session):**
```bash
# Source the helper library (copy the block from api-auth.md first)
source "${_hawk_lib}"
# Required environment variables
export HAWK_API_KEY="hawk.xxxxxxxxxxxx" # from app.stackhawk.com → Settings → API Keys
export ORG_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # from platform URL or auth response
```
---
## Recipe 1: Org Security Posture
**Question:** Across all my apps, what is the current security situation?
**What it produces:** One row per environment showing untriaged finding counts by
severity, sorted by High findings descending. Environments with no recent scan are
flagged.
### Step 1 — Fetch all environments
```bash
all_envs=$(hawk_api_all_pages "/api/v2/org/${ORG_ID}/envs" "environments" 200)
```
### Step 2 — Build the posture table
```bash
printf '%s' "${all_envs}" | jq -r '
# Compute seconds-since-scan for each env
def age_days(ts):
if ts == null then "NEVER"
else
(now - (ts | sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime))
/ 86400 | floor | tostring
end;
# Flag envs with no scan or scan older than 30 days
def stale(ts):
if ts == null then "STALE (never scanned)"
else
(now - (ts | sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime))
/ 86400 | floor as $d
| if $d > 30 then "STALE (\($d)d ago)" else "" end
end;
# Sort by High desc, then Medium desc
sort_by([(.lastScanHighUntriaged // 0) * -1, (.lastScanMediumUntriaged // 0) * -1])
| ["APP_ID", "ENVIRONMENT", "HIGH", "MEDIUM", "LOW", "LAST_SCAN", "FLAG"],
(.[] | [
.applicationId,
.environmentName,
(.lastScanHighUntriaged // 0 | tostring),
(.lastScanMediumUntriaged // 0 | tostring),
(.lastScanLowUntriaged // 0 | tostring),
(.lastScanTimestamp // "never"),
stale(.lastScanTimestamp)
])
| @tsv
' | column -t -s $'\t'
```
### Step 3 — Summary totals
```bash
printf '%s' "${all_envs}" | jq '
{
total_envs: length,
total_high: (map(.lastScanHighUntriaged // 0) | add // 0),
total_medium: (map(.lastScanMediumUntriaged // 0) | add // 0),
total_low: (map(.lastScanLowUntriaged // 0) | add // 0),
never_scanned: [.[] | select(.lastScanTimestamp == null)] | length,
stale_over_30d: [.[] | select(
.lastScanTimestamp != null and
((now - (.lastScanTimestamp | sub("\\.[0-9]+Z$"; "Z")
| strptime("%Y-%m-%dT%H:%M:%SZ") | mktime)) / 86400) > 30
)] | length
}
'
```
### Output format
```
APP_ID ENVIRONMENT HIGH MEDIUM LOW LAST_SCAN FLAG
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Production 5 12 8 2024-01-15T10:30:00Z
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Staging 3 4 1 2024-01-10T09:00:00Z
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Development 0 0 0 2023-11-01T08:00:00Z STALE (75d ago)
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx QA 0 0 0 never STALE (never scanned)
```
> **Note:** `APP_ID` is the `applicationId` UUID. To show human-readable app names,
> fetch all apps with `hawk_api_all_pages "/api/v2/org/${ORG_ID}/apps" "applications"`
> and join on `applicationId` before printing.
---
## Recipe 2: App Deep Dive
**Question:** What did the last scan of a specific app find?
**What it produces:** All alerts from the most recent scan, grouped by severity,
with affected URIs expanded for High and Medium findings.
### Prerequisites
```bash
APP_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # application UUID
```
### Step 1 — Get the latest scan for the app
```bash
# Fetch the most recent scan result for this application
latest_scan=$(hawk_api GET "/api/v1/scan/${ORG_ID}?pageSize=200" \
| jq --arg app "${APP_ID}" '
.applicationScanResults
| map(select(.applicationId == $app and .status == "COMPLETED"))
| sort_by(.startedTimestamp)
| reverse
| .[0]
')
SCAN_ID=$(printf '%s' "${latest_scan}" | jq -r '.scanId')
SCAN_TS=$(printf '%s' "${latest_scan}" | jq -r '.startedTimestamp')
echo "Latest completed scan: ${SCAN_ID} (started ${SCAN_TS})"
echo "Platform URL: https://app.stackhawk.com/scans/${SCAN_ID}"
```
If the app has more scans than one page returns, paginate to find the most recent:
```bash
all_scans=$(hawk_api_all_pages "/api/v1/scan/${ORG_ID}" "applicationScanResults")
latest_scan=$(printf '%s' "${all_scans}" | jq --arg app "${APP_ID}" '
map(select(.applicationId == $app and .status == "COMPLETED"))
| sort_by(.startedTimestamp)
| reverse
| .[0]
')
SCAN_ID=$(printf '%s' "${latest_scan}" | jq -r '.scanId')
```
### Step 2 — List all alerts for the scan
```bash
all_alerts=$(hawk_api_all_pages "/api/v1/scan/${SCAN_ID}/alerts" "applicationAlert")
# Print alert summary sorted by severity (High → Medium → Low)
printf '%s' "${all_alerts}" | jq -r '
def sev_rank: if . == "High" then 0 elif . == "Medium" then 1 else 2 end;
sort_by(.severity | sev_rank)
| ["SEVERITY", "PLUGIN_ID", "ALERT_NAME", "CWE", "AFFECTED_URIS"],
(.[] | [
.severity,
.pluginId,
.alertName,
(.cweId // "—"),
(.affectedUriCount | tostring)
])
| @tsv
' | column -t -s $'\t'
```
### Step 3 — Expand affected paths for High and Medium alerts
```bash
# Collect High and Medium plugin IDs
high_med_ids=$(printf '%s' "${all_alerts}" | jq -r '
.[] | select(.severity == "High" or .severity == "Medium") | .pluginId
')
# For each High/Medium alert, fetch the affected URIs
while IFS= read -r plugin_id; do
[[ -z "${plugin_id}" ]] && continue
alert_name=$(printf '%s' "${all_alerts}" \
| jq -r --arg pid "${plugin_id}" '.[] | select(.pluginId == $pid) | .alertName')
severity=$(printf '%s' "${all_alerts}" \
| jq -r --arg pid "${plugin_id}" '.[] | select(.pluginId == $pid) | .severity')
echo ""
echo "=== [${severity}] ${alert_name} (plugin ${plugin_id}) ==="
hawk_api GET "/api/v1/scan/${SCAN_ID}/alert/${plugin_id}?pageSize=100" \
| jq -r '
["METHOD", "URI", "PARAMETER", "TRIAGE_STATUS"],
(.applicationScanAlertUris[] | [
.method,
.uri,
(.parameter // "—"),
(.triageStatus // "New")
])
| @tsv
' | column -t -s $'\t'
done <<< "${high_med_ids}"
```
### Output format
```
SEVERITY PLUGIN_ID ALERT_NAME CWE AFFECTED_URIS
High 40012 Cross-Site Scripting (Reflected) CWE-79 3
High 90022 Application Error Disclosure — 1
Medium 10038 Content Security Policy (CSP) — 8
Low 10096 Timestamp Disclosure — 2
=== [High] Cross-Site Scripting (Reflected) (plugin 40012) ===
METHOD URI PARAMETER TRIAGE_STATUS
POST /api/users/search q New
GET /api/products name New
GET /api/items/filter category Reopened
```
**Platform link** (always include when presenting results):
```
https://app.stackhawk.com/scans/${SCAN_ID}
```
---
## Recipe 3: Stale Apps
**Question:** Which apps have not been scanned recently?
**What it produces:** All environments where the last scan is either missing or
older than 30 days, sorted by staleness (longest gap first).
### Step 1 — Fetch all environments
```bash
all_envs=$(hawk_api_all_pages "/api/v2/org/${ORG_ID}/envs" "environments" 200)
```
### Step 2 — Filter and sort stale environments
```bash
STALE_DAYS=30 # adjust threshold as needed
printf '%s' "${all_envs}" | jq -r --argjson threshold "${STALE_DAYS}" '
def days_since(ts):
if ts == null then 999999 # never scanned — sort to top
else
(now - (ts | sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime))
/ 86400 | floor
end;
def display_last_scan(ts):
if ts == null then "never" else ts end;
def display_days(ts):
if ts == null then "never scanned"
else (days_since(ts) | tostring) + " days"
end;
# Keep only stale envs
[.[] | select(days_since(.lastScanTimestamp) > $threshold)]
# Sort: never-scanned first, then longest gap first
| sort_by(days_since(.lastScanTimestamp) * -1)
| ["APP_ID", "ENVIRONMENT", "LAST_SCAN", "DAYS_SINCE_SCAN"],
(.[] | [
.applicationId,
.environmentName,
display_last_scan(.lastScanTimestamp),
display_days(.lastScanTimestamp)
])
| @tsv
' | column -t -s $'\t'
```
### Step 3 — Count summary
```bash
printf '%s' "${all_envs}" | jq --argjson threshold "${STALE_DAYS}" '
def days_since(ts):
if ts == null then 999999
else (now - (ts | sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime)) / 86400 | floor
end;
{
total_environments: length,
stale_count: [.[] | select(days_since(.lastScanTimestamp) > $threshold)] | length,
never_scanned: [.[] | select(.lastScanTimestamp == null)] | length,
threshold_days: $threshold
}
'
```
### Output format
```
APP_ID ENVIRONMENT LAST_SCAN DAYS_SINCE_SCAN
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx QA never never scanned
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Development 2023-09-01T08:00:00Z 116 days
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Staging 2023-11-20T14:00:00Z 55 days
```
### Recommendation
For each stale environment, present the following action:
> **Action required:** Environment `<environmentName>` (app `<applicationId>`) has not
> been scanned in `<N>` days. Run a HawkScan against this environment to refresh the
> security posture data. See the `hawkscan` plugin for scan configuration and
> invocation instructions.
---
## Recipe 4: What Changed Since Last Scan
**Question:** What is new or resolved compared to the previous scan of this app?
**What it produces:** Two sections — "New findings" (alerts in the latest scan but
not in the previous) and "Resolved findings" (alerts in the previous scan but not in
the latest), diffed by `pluginId`.
### Prerequisites
```bash
APP_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # application UUID
```
### Step 1 — Get the two most recent completed scans for the app
```bash
all_scans=$(hawk_api_all_pages "/api/v1/scan/${ORG_ID}" "applicationScanResults")
# Extract the two most recent COMPLETED scans for the target app
two_scans=$(printf '%s' "${all_scans}" | jq --arg app "${APP_ID}" '
[.[] | select(.applicationId == $app and .status == "COMPLETED")]
| sort_by(.startedTimestamp)
| reverse
| .[0:2]
')
LATEST_SCAN_ID=$(printf '%s' "${two_scans}" | jq -r '.[0].scanId')
PREV_SCAN_ID=$(printf '%s' "${two_scans}" | jq -r '.[1].scanId')
LATEST_TS=$(printf '%s' "${two_scans}" | jq -r '.[0].startedTimestamp')
PREV_TS=$(printf '%s' "${two_scans}" | jq -r '.[1].startedTimestamp')
echo "Latest scan : ${LATEST_SCAN_ID} (${LATEST_TS})"
echo "Previous scan: ${PREV_SCAN_ID} (${PREV_TS})"
# Guard: need at least 2 scans to diff
if [[ "${LATEST_SCAN_ID}" == "null" || "${PREV_SCAN_ID}" == "null" ]]; then
echo "ERROR: Need at least 2 completed scans for this app to compute a diff." >&2
exit 1
fi
```
### Step 2 — Fetch alerts for both scans
```bash
latest_alerts=$(hawk_api_all_pages "/api/v1/scan/${LATEST_SCAN_ID}/alerts" "applicationAlert")
prev_alerts=$(hawk_api_all_pages "/api/v1/scan/${PREV_SCAN_ID}/alerts" "applicationAlert")
```
### Step 3 — Diff the alert sets by pluginId
```bash
# Build lookup sets and compute new vs. resolved
diff_result=$(jq -n \
--argjson latest "${latest_alerts}" \
--argjson prev "${prev_alerts}" '
{
latest_ids: ($latest | map(.pluginId) | unique),
prev_ids: ($prev | map(.pluginId) | unique),
latest_map: ($latest | map({(.pluginId): .}) | add // {}),
prev_map: ($prev | map({(.pluginId): .}) | add // {})
} as $data
| {
new_findings: (
$data.latest_ids - $data.prev_ids
| map($data.latest_map[.])
| sort_by(.severity | if . == "High" then 0 elif . == "Medium" then 1 else 2 end)
),
resolved_findings: (
$data.prev_ids - $data.latest_ids
| map($data.prev_map[.])
| sort_by(.severity | if . == "High" then 0 elif . == "Medium" then 1 else 2 end)
),
unchanged_count: (
[$data.latest_ids[], $data.prev_ids[]] | group_by(.) | map(select(length == 2)) | length
)
}
')
```
### Step 4 — Present the diff
```bash
echo ""
echo "======================================================================"
echo "SCAN DIFF: ${APP_ID}"
echo " Latest: ${LATEST_SCAN_ID} (${LATEST_TS})"
echo " Previous: ${PREV_SCAN_ID} (${PREV_TS})"
echo "======================================================================"
echo ""
echo "--- NEW FINDINGS (appeared in latest scan) ---"
printf '%s' "${diff_result}" | jq -r '
if (.new_findings | length) == 0 then " (none)"
else
["SEVERITY", "PLUGIN_ID", "ALERT_NAME", "CWE", "AFFECTED_URIS"],
(.new_findings[] | [
.severity,
.pluginId,
.alertName,
(.cweId // "—"),
(.affectedUriCount | tostring)
])
| @tsv
end
' | column -t -s $'\t'
echo ""
echo "--- RESOLVED FINDINGS (present in previous scan, gone in latest) ---"
printf '%s' "${diff_result}" | jq -r '
if (.resolved_findings | length) == 0 then " (none)"
else
["SEVERITY", "PLUGIN_ID", "ALERT_NAME", "CWE", "AFFECTED_URIS_PREV"],
(.resolved_findings[] | [
.severity,
.pluginId,
.alertName,
(.cweId // "—"),
(.affectedUriCount | tostring)
])
| @tsv
end
' | column -t -s $'\t'
echo ""
printf '%s' "${diff_result}" | jq -r '
"Summary: \(.new_findings | length) new, \(.resolved_findings | length) resolved, \(.unchanged_count) unchanged"
'
echo ""
echo "Platform links:"
echo " Latest scan: https://app.stackhawk.com/scans/${LATEST_SCAN_ID}"
echo " Previous scan: https://app.stackhawk.com/scans/${PREV_SCAN_ID}"
```
### Output format
```
======================================================================
SCAN DIFF: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Latest: aaa-bbb-ccc (2024-01-15T10:00:00Z)
Previous: ddd-eee-fff (2024-01-10T09:00:00Z)
======================================================================
--- NEW FINDINGS (appeared in latest scan) ---
SEVERITY PLUGIN_ID ALERT_NAME CWE AFFECTED_URIS
High 40012 Cross-Site Scripting (Reflected) CWE-79 3
Medium 10038 Content Security Policy (CSP) — 8
--- RESOLVED FINDINGS (present in previous scan, gone in latest) ---
SEVERITY PLUGIN_ID ALERT_NAME CWE AFFECTED_URIS_PREV
Medium 10021 X-Content-Type-Options Header — 1
Summary: 2 new, 1 resolved, 4 unchanged
Platform links:
Latest scan: https://app.stackhawk.com/scans/aaa-bbb-ccc
Previous scan: https://app.stackhawk.com/scans/ddd-eee-fff
```
> **Note:** The diff is based on `pluginId` (vulnerability type). An alert counted
> as "resolved" means the scanner no longer detected that vulnerability class — it
> does not guarantee the underlying issue was fixed in code. Always verify remediation
> through the platform before closing findings.