Companion skills:
osint-methodology(the 5-stage pipeline this skill loops — see its §7.2 "ongoing weekly diff" profile, which this skill fills in with concrete mechanics),offensive-osint(§29 Threat Intel & IOCs — this skill deepens that section's advisory/IOC-feed directory with the continuous adversary-chatter watch loop and CTI-feed cadence it explicitly lacks; use §29 for indicator enrichment and vulnerability-prioritization data sources, this skill for the standing collection loop),org-attack-surface(the org-first discovery this skill's re-scans re-run on a schedule),exposure-risk-quantification(reads this skill's finding-lifecycle suppression state to computerisk_trendand the FAIR score — see its risk-score model). This skill answers a different question than all four: not "what does the target expose right now," but "is what the target exposes changing, and should anyone be told."
Use this skill when:
Do NOT use this skill when:
osint-methodology (5-stage
pipeline) and offensive-osint (the per-technique arsenal). This skill assumes discovery already
happened at least once and is about the second and every subsequent run.Same base posture as the companion skills: intended for assets the operator owns or has written
authorization to assess — see osint-methodology §1 for the full soft-scope-check script.
The monitoring-specific nuance: a point-in-time engagement authorization does not automatically
cover an indefinite recurring cadence. Before standing up a monitor/chatter watch daemon or a
dashboard-scheduled job against a target, confirm the authorization window explicitly covers the
monitoring period (retainer end date, RoE renewal terms), not just "the engagement." A scheduled job
that outlives its authorization is a standing violation, not a one-off mistake — it fires every
interval until someone notices and tears it down.
Always-on guardrail specific to this skill: a monitoring loop must never silently escalate its
own authorization tier. The monitor CLI command's flag surface only exposes --only/--exclude
module filters — it has no path to enable --validate (the credential/token-submission tier), so a
scheduled asm-cli monitor loop cannot accidentally re-arm that tier. The dashboard job
scheduler is different: a job's flags field accepts the same flags a manual scan does, so an
operator can configure a recurring job with --validate/--validate-creds set. Treat that as a
standing-authorization decision that needs its own explicit sign-off, not a monitoring-cadence
decision — see §5.
Same three-tier scale as the companion skills — TENTATIVE / FIRM / CONFIRMED — answers "how sure am I this is real." This skill adds one axis that is easy to conflate with confidence and must not be:
Confidence is not lifecycle status. A chatter hit can be HIGH severity and still TENTATIVE
confidence (adversary-controlled content is always TENTATIVE by design — see §7.6). A finding can be
CONFIRMED confidence and still be risk_accepted in the lifecycle (§9) — the client acknowledged a
real exposure and chose to keep it. Never let a severity or confidence label imply anything about
whether a human has already triaged the finding, and never let a lifecycle status imply anything
about whether the underlying evidence was independently verified. They are orthogonal axes recorded
in different places (the Finding's confidence field vs. finding_lifecycle.status).
Three schemas this skill's outputs use. All timestamps UTC ISO-8601.
Finding — same schema as the companion skills (see osint-methodology §3); chatter-sourced
findings always carry confidence: tentative (§7.6).
Delta event — what a monitoring cycle reports:
DeltaEvent:
scan_a_id / scan_b_id: older / newer scan
scan_a_time / scan_b_time
kind: new_asset | removed_asset | changed_asset | new_finding | resolved_finding
asset_or_finding_key: the asset key, or the finding's fingerprint::asset_key::title
type_or_category: asset type, or finding category
severity: info|low|medium|high|critical (findings only)
changes: {attr: [old, new]} (changed_asset only)
alert_fired: bool — did this event cross the configured threshold
Lifecycle record — cross-scan identity for one finding at one target (see §9):
LifecycleRecord:
target: lowercased registrable domain
fingerprint: 16-hex sha1, stable across scans (§9.2)
status: open | triaged | risk_accepted | resolved | false_positive
first_seen_ever: UTC, earliest scan_started_at this fingerprint appeared in
last_present_scan/at: most recent scan that reproduced this fingerprint
age_scans: count of scans this fingerprint has appeared in
sla_due: UTC, computed from severity at first insert (§9.4)
triaged_by/at, resolved_by/at, notes
Same discipline as the companion skills: URL + UTC timestamp + SHA-256 + tool version + run_id per
artifact. One monitoring-specific addition: the chatter corpus tables (ransomware_corpus) persist
every observed row regardless of whether it currently matches a watchlist, precisely so retroactive
analysis (§7.4) has real historical data to search rather than needing to re-fetch a since-rotated
feed. Treat that corpus as an evidence store in its own right — dedup on the natural key
(source/group/victim), never delete rows to "clean up," and cite corpus_id alongside the hit id
when a finding traces back to a retroactive match.
resolved, false_positive, or
risk_accepted just because a re-scan rediscovered it — see §9.6 for the exact mechanics and which
alerting subsystem actually protects against this (only one of the two does, natively).--validate/--validate-creds unless
that specific standing authorization was explicitly confirmed — see §1.osint-methodology §6.1 (OpSec) requires generally.findings_watchlist rule's severity_min or drop its category/module
filters to "catch more" without first reading §9.6 and §10.5 — an unscoped rule on this subsystem
re-fires every re-scan on a persisting issue, unlike the fingerprint-diff alerting in §6.The canonical pattern (monitor.py):
interval seconds.A first run against a fresh target has no prior scan to diff against — the loop logs "first scan — no prior baseline to diff against; skipping alert" and simply establishes the baseline. Nothing alerts on run #1 by design.
A mature ASM implementation ships two ways to run this loop — pick based on scope:
| CLI foreground daemon | Dashboard job scheduler | |
|---|---|---|
| Entry point | asm-cli monitor <target> ... |
monitor_scheduler.scheduler_daemon_loop |
| Scope | one target per process | many jobs, persisted in MonitorJobStore |
| Visibility | own stdout only | dispatched via ScanManager.launch() — appears in the live dashboard scan list |
| Poll/dispatch cadence | the loop's own --interval |
polls for due jobs every 30s (_POLL_SECONDS) |
| Per-run safety cap | none — interval only | 7200s (2h) _SCAN_MAX_SECONDS; a wedged scan is cancelled and the loop moves on (already rescheduled) |
| Disable | Ctrl-C | ASM_DISABLE_MONITOR_SCHEDULER=1 |
--validate reachable? |
No — the CLI's flag surface is --only/--exclude only |
Yes — job flags accepts the same flags a manual scan does; see §1 |
Both funnel through the same diff-and-alert logic (monitor._maybe_alert) — the scheduler literally
imports and calls it, so §6.5's alert-worthy rule is identical either way.
The diff engine (reporting/diff.py) compares two scan.db files and produces:
key.| Asset type | Tracked attrs | Asset type | Tracked attrs |
|---|---|---|---|
webapp |
status, title, server, tech, waf, cdn_name, jarm, favicon_hash, content_hash, redirect_off_scope |
certificate |
not_after, issuer, self_signed |
port |
ip, port, transport |
bucket |
provider, listable, public |
service |
ip, port, product |
credential |
email, breach |
subdomain, ip, email, person, repo, domain, typosquat_domain, wayback_endpoint, asn, netblock |
existence-only — no attrs tracked |
A note on existence-only types: since an asset's key typically embeds its value (e.g.
ip:1.2.3.4), a subdomain re-pointing to a new IP does not surface as a "changed" event — it
surfaces as a paired removed_asset (old IP) + new_asset (new IP), because the key itself
changed. Read a re-point out of the new/removed pairing, not out of changed_assets.
asset_key::title key). A finding present in scan B but not scan A is
"new"; present in A but not B is "resolved." Fingerprint mechanics — including why a resolved
finding reappearing is not silently treated as fine — are in §9.Not every stage is worth re-running on the same schedule. Passive re-enum is cheap; active probing
costs quota, time, and detection budget (osint-methodology §6.4 back-off ladder still applies to
every active re-run).
| Stage (orchestrator numbering) | What's in it | Cost profile | Suggested cadence |
|---|---|---|---|
| 1 — Seed discovery | whois_mod, asn, dns_deep, certs, pdns |
Cheap, keyless, passive | Daily is fine |
| 2 — Asset expansion | subdomain (brute-force component), cloud_buckets, wayback, typosquat, mobile_attack_surface |
Moderate; some active probing (bucket HEAD/GET, DNS resolution of typosquat candidates) | Daily-to-weekly |
| 3 — Enrichment | ports, host_intel, web_probe, email_osint, github_recon, sso_idp, api_discovery |
First real active web traffic; cost scales with alive-host count | Weekly |
| 4 — Exposure analysis | nuclei_mod, path_fuzz, param_discovery, screenshots, bypass403, paramminer |
Heaviest active probing in the default-on tier | Weekly, or slower on a large asset count — detection-aware back-off still governs |
| 5 — Recursion + supply-chain | recon_recurse, aws_account_enum, dependency_confusion |
Pairs with whatever cadence you picked for a full re-scan | Same as the full-scan cadence |
6 — --validate tier |
credential/token-submission modules | Highest authorization tier | Never on an unattended schedule without a human re-confirming scope each run — see §1 |
Rule of thumb: if the client's driver is "tell me about new subdomains, certs, buckets, and ransomware chatter," a daily stage-1/2-only loop plus the chatter daemon (§7) covers it cheaply. If the driver is "tell me about new exposed misconfigurations," you need stage 4, and weekly is the realistic floor once asset count is non-trivial.
monitor._maybe_alert fires when either:
--threshold (default medium), orcredential, typosquat_domain, bucket,
repo — these fire regardless of severity threshold, because a brand-new credential leak,
lookalike domain, exposed bucket, or newly-discovered repo is always worth a look even before
triage assigns it a severity.Nothing else in the diff — resolved findings, changed assets, new low-severity findings outside the hot-asset types — triggers a webhook on its own. It's still in the diff output and the Markdown report (§6.3), just not pushed.
The chatter/ subsystem is the standing adversary-side watch: six public sources polled on their own
cadence, matched against an operator watchlist, classified into severity, and (above threshold)
pushed to a webhook.
| Source | Kind | Endpoint | Cadence | Key |
|---|---|---|---|---|
ransomwatch |
leak_site | https://raw.githubusercontent.com/joshhighet/ransomwatch/main/posts.json |
300s (5 min) | none |
ransomware_live |
leak_site | https://api.ransomware.live/v2/recentvictims |
300s | none |
hackernews |
forum | https://hn.algolia.com/api/v1/search (Algolia HN search) |
600s (10 min) | none |
reddit |
forum | https://www.reddit.com/r/<sub>/.rss across cybersecurity, netsec, InfoSecNews, blueteamsec, Pwned, AskNetsec, sysadmin |
600s | none |
paste:gist |
paste | https://api.github.com/search/code (filtered to gist.github.com results) |
900s (15 min) | GITHUB_TOKEN |
telegram |
telegram | https://t.me/s/<channel> public web preview |
1800s (30 min) | none, but needs channels configured under market_intel.telegram.channels (seed with asm-cli breach discover --kind telegram) |
The two leak-site sources (ransomwatch, ransomware_live) each poll their full feed on every
cycle and persist every row to a local ransomware_corpus table — not just watchlist matches.
That full-corpus capture is what makes §7.4's retroactive rescan possible.
hackernews and reddit always inject a set of generic seed keywords (data breach, ransomware,
0day, supply chain attack, vulnerability, CVE for HN; a broader breach/leak/exploit list for
Reddit) alongside the operator's watchlist, so the chatter dashboard shows ambient threat-landscape
chatter even before a watchlist exists — those hits are tagged relevance: ambient and are excluded
from client-facing promotion (§7.6).
Every source routes matching through a shared compiler (chatter/matching.py). Three pattern types,
all case-insensitive:
| Type | Semantics | Default |
|---|---|---|
literal |
substring match | yes |
glob |
fnmatch wildcards (*, ?, [seq]) |
|
regex |
full Python regex, search-mode |
A glob or regex pattern that fails to compile falls back to literal match with a logged warning — one bad pattern can never wedge the whole collection cycle. Add a pattern:
asm-cli chatter watchlist add "acme*.com" --kind domain --pattern-type glob \
--priority high --notes "primary + all TLD variants"
asm-cli chatter watchlist add "acme*.com" --kind domain --pattern-type glob `
--priority high --notes "primary + all TLD variants"
--kind is one of domain | brand | keyword | email — it feeds the severity engine below (a
domain-kind match ranks higher than a brand/keyword match at the same source).
chatter/classifier.py::classify() — never raises, always returns a (severity, category, classification, reason) tuple. The rubric is source-kind-aware, not a flat rule:
| Source kind | Signal | Severity |
|---|---|---|
leak_site |
domain-kind match + a named ransomware group recognized in the text | CRITICAL |
leak_site |
any other match on a leak-site listing | HIGH |
forum |
IAB-style phrase (see below) + domain match + a price/currency marker in the excerpt | HIGH |
forum |
IAB phrase + domain match, no price | MEDIUM |
forum |
domain match, no IAB phrase | MEDIUM |
forum |
brand/keyword match | LOW |
telegram |
secret-regex hit in the post text | HIGH |
telegram |
domain match + IAB phrase | CRITICAL |
telegram |
domain match, no IAB phrase | HIGH |
telegram |
email match | HIGH |
telegram |
brand/keyword match | MEDIUM |
telegram |
any other match | MEDIUM (floor — adversary channels never rank below this) |
| paste / other | secret-regex hit in the excerpt | HIGH |
| paste / other | domain match + IAB phrase | HIGH |
| paste / other | domain match, no IAB phrase | MEDIUM |
| paste / other | brand/keyword match | LOW |
| paste / other | email match | MEDIUM |
| paste / other | no rule fired | INFO |
Note the telegram tier floors one step above the equivalent paste-site tier for every pattern kind — an adversary-run channel is treated as inherently higher-signal than an ambient paste dump.
IAB-style phrases (substring, lowercased): access , rdp , vpn, domain admin, citrix,
esxi, netscaler, admin panel, exchange server, initial access, shell/reverse shell/
webshell, privileged account, oauth token.
Recognized ransomware groups (partial — checked case-insensitively against the post text):
lockbit, akira, cl0p/clop, ransomhub, play, blacksuit, medusa, qilin, rhysida,
8base, cactus, inc ransom, hunters international, darkvault, embargo, dragonforce,
kasseika, plus legacy names still seen on mirrors: revil, conti, alphv/blackcat,
blackbyte, royal, vice society, bianlian, snatch.
A hit's severity can also be promoted — never demoted — by a source-supplied severity_hint
(HackerNews/Reddit infer high/medium/low/info from title keywords like "leak"/"breach"/"CVE"/
"advisory" before the classifier even runs) when the hint outranks the classifier's own verdict.
Adding a watchlist entry (chatter watchlist add, default rescan=True) doesn't just start matching
future chatter — it immediately searches the full historical ransomware_corpus for matches
already sitting in the corpus:
LIKE search (fast, unbounded).Matches found this way are inserted as normal chatter_hits rows but are pre-marked alerted=1 —
the daemon will not webhook-spam the operator with 18-month-old news the moment a new watchlist
entry is added. This is what solves "the client was hit by this group over a year ago and we only
just started watching for them" — the historical hit shows up in the dashboard and the hit-detail
view, silently, without an alert storm.
Both leak-site sources persist every row they see on every poll to ransomware_corpus, dedup'd on a
natural key, regardless of whether anything currently on the watchlist matches. This is the
precondition for §7.4 and for any later trend-tracking query ("how many of our client's peers in this
sector have appeared on a leak site in the last 90 days") — without full-corpus capture, a watchlist
added today can only ever see chatter from today forward.
chatter/promote.py::promote_hit() — a one-way copy of a chatter hit into a scan's asset+finding
tables, mirroring the dorking-results promotion pattern:
AssetType.WAYBACK (the existing "externally-harvested URL" type), tagged
sources=["chatter:<source>"].Category.INFO_DISCLOSURE, severity from the classifier, confidence always
TENTATIVE — chatter is adversary-controlled content by definition, and the description text
explicitly instructs: "Open the URL ONLY in an isolated browser / VM / Tails. Validate
authenticity before escalating."
min_finding_severity (default medium) — low/info hits stay
asset-only, visible in the dashboard, not promoted as a finding.relevance is ambient (matched only
a generic seed keyword, no watchlist pattern behind it — §7.1) returns PromotionResult(ok=False, ...) unconditionally. There is no client-specific signal behind an ambient hit, so it must never
reach the client-facing deliverable or scan.db findings — only a genuine watchlist match can.asm-cli chatter watch --webhook https://hooks.slack.com/services/XXX/YYY/ZZZ \
--threshold high --tick 30
asm-cli chatter watch --webhook https://hooks.slack.com/services/XXX/YYY/ZZZ `
--threshold high --tick 30
--threshold (default high) is the severity floor for the webhook, independent of §6.5's
monitor threshold — they're two separate daemons with two separate knobs (§10.5). --tick is how
often the daemon checks whether any source's per-source cadence (the table in §7.1) has elapsed; it
does not itself poll every tick.
This methodology assumes no push/streaming CT-log feed — every signal in this section is poll + diff: re-run the relevant module on the loop's cadence (§6.4) and read the delta out of §6.3's asset diff. What follows is which module produces which tracked-attribute signal, and what it means when it fires.
| Signal | Module (re-run cadence from §6.4) | Where it shows up in the diff |
|---|---|---|
| New certificate | certs (stage 1, cheap, safe daily) — crt.sh + certspotter + leakix union |
New certificate asset; tracked attrs not_after/issuer/self_signed on the existing cert if reissued |
| New subdomain | subdomain, certs, pdns, wayback (any of these can surface a new hostname) |
New subdomain asset (existence-only — presence is the signal) |
| Passive-DNS delta / IP re-point | pdns (stage 1, HackerTarget keyless + optional OTX) |
A subdomain's IP changing shows as a paired removed+new ip asset (§6.3's existence-only-type note), not a "changed" event |
| New open port / service change | ports (stage 3 — Shodan InternetDB passive, or --probe active native scan) |
New port asset (tracked ip/port/transport); a fingerprint/product change on an existing port is a changed_assets entry (tracked ip/port/product on service) |
| New typosquat / lookalike domain | typosquat (stage 2 — dnstwist permutations + DNS resolution of candidates) |
New typosquat_domain asset — always fires the webhook per §6.5's hot-asset-type rule, regardless of severity threshold; a mail-capable lookalike is escalated to HIGH by the module itself |
The single highest-signal "perimeter drift" event class this platform ships is a new
typosquat_domain, credential, bucket, or repo asset — all four are in monitor.py's
hot-asset-type override (§6.5), meaning they alert regardless of the configured severity threshold.
If a client asks "what's the one thing I should never have muted," it's these four asset types.
Either let the loop do it automatically (§6, §11) or run it by hand:
# Last week's scan id vs. this week's, most-recent-first in scans/
asm-cli diff acme_com_20260730 acme_com_20260806 \
--scans-root scans --out reports/weekly-diff.md --json reports/weekly-diff.json
asm-cli diff acme_com_20260730 acme_com_20260806 `
--scans-root scans --out reports/weekly-diff.md --json reports/weekly-diff.json
The Markdown report (reporting/diff.py::render_markdown) sections, in order: Summary counts → 🆕
NEW findings (sorted highest severity first) → ✅ RESOLVED findings → 🆕 NEW assets (grouped by type)
→ ➖ REMOVED assets (grouped by type) → 🔄 CHANGED assets (with the old→new value for every changed
attr). Each section caps at 50 items with a …N more ellipsis so a noisy diff doesn't blow out the
report.
The fleet/ subsystem is what lets a finding have a state that persists across scans, instead of
every re-scan treating every finding as new.
open → triaged → {risk_accepted | resolved | false_positive}
↑____________________________| (resolved can REOPEN to open — see §9.3)
open, triaged, risk_accepted, resolved, false_positive — enforced enum
(_LIFECYCLE_STATUSES). Two of the five are suppressed — excluded from dashboard counters, the
FAIR risk grade, and attack-path chains: false_positive and resolved.
risk_accepted is deliberately not suppressed. It's a real, acknowledged exposure the client
chose to keep — it must still count toward the risk grade and still appear in attack-path reasoning.
This is the single most common lifecycle mistake to correct: "the client accepted the risk" does not
mean "this finding no longer matters for scoring," it means "the client made an informed decision
about a finding that is still real."
fingerprint = sha1(f"{module}\x1f{asset_key}\x1f{category.value}\x1f{normalized_title}")[:16]
normalized_title = lowercased, all digit runs replaced with #, whitespace collapsed. The
digit-normalization is deliberate: a title like "Open port 8080 exposed" and "Open port 9090 exposed"
both normalize to the same string (open port # exposed), so the same underlying misconfiguration
type on the same asset stays one lifecycle identity even as incidental digits in the title drift
between scans. The \x1f (ASCII unit separator) between fields avoids cross-field collisions from
any field that happens to contain a more common delimiter.
Two related-but-different keys exist — do not conflate them:
fleet_findings is keyed (scan_id, fingerprint) — one row per scan, the per-scan snapshot.finding_lifecycle is keyed (target, fingerprint) — one row per target, the cross-scan
identity that actually carries the state machine.Runs automatically at the end of every scan (fleet.promote.promote_scan_into_fleet →
FleetStore.reconcile_lifecycle), given the full set of fingerprints present in the just-completed
scan:
INSERT status='open', age_scans=1, sla_due
computed from severity (§9.4).age_scans += 1, last_present_scan/at bumped. If its
status was resolved, it is reopened to open — a resolved issue coming back is a real
regression and must not be silently treated as still-fine.open or triaged, and it was last
present in a strictly earlier scan (same-scan idempotence guard) → auto-resolved
(resolved_by='auto').risk_accepted and false_positive fingerprints are untouched by reconciliation either way — they
don't auto-resolve when absent (nothing to resolve, the operator already made a call) and they don't
silently flip back to open on reappearance the way resolved does; they stay exactly as the
operator left them unless a human changes the status.
sla_due is computed once, at first insert, from the finding's severity:
| Severity | SLA (days) |
|---|---|
| critical | 7 |
| high | 14 |
| medium | 30 |
| low | 90 |
| info | none |
fleet/sla.py::sla_state() classifies a lifecycle row against now: overdue (active status, past
sla_due), due_soon (within 3 days of due), on_track, or none (closed status, or an
info-severity finding with no SLA at all). Only open/triaged rows can be overdue or
due_soon — a risk_accepted/resolved/false_positive row is never flagged against the SLA clock
regardless of how old it is.
fleet.store.suppressed_fingerprints(target) — the single source of the suppression rule — is
consumed by reporting/risk_score.py and reporting/attack_paths.py. A false_positive or
resolved fingerprint is invisible to both; a risk_accepted one is visible to both (§9.1). See
exposure-risk-quantification for how that feeds the FAIR score and risk_trend.
This is the answer to the recurring question "why did I get alerted again on a finding we already marked risk-accepted / false-positive":
Two separate alerting subsystems exist, and only one of them is lifecycle-aware:
| Subsystem | Dedup key | Lifecycle-aware? |
|---|---|---|
monitor / monitor_scheduler diff alerting (§6) |
the finding's fingerprint — a re-scan that reproduces the same fingerprint is not a "new" finding, full stop | Yes, structurally — same fingerprint can never appear in new_findings twice |
findings_watchlist rule matching |
(rule_id, finding_id) where finding_id is the per-scan database row id, freshly generated by every scan |
No — _matches_rule() checks severity/category/module/target-pattern/title-regex/KEV/EPSS only; it never reads finding_lifecycle. A fresh scan produces a fresh finding_id for the same underlying fingerprint, so the (rule_id, finding_id) dedup key does not recognize it as a repeat |
The practical consequence: a risk_accepted finding that keeps reappearing in every re-scan will
not re-trigger a monitor webhook (its fingerprint isn't "new"), but a broad
findings_watchlist rule with a low severity_min and no category/module narrowing will
re-fire on it every single time the daemon's cursor sees a scan that reproduces it — because from
that rule's point of view, it's a fresh finding row it hasn't alerted on before.
Recommendations, in priority order:
monitor's fingerprint-diff alerting for "tell me about new things" — it's the only one
with lifecycle-equivalent protection built in.findings_watchlist rules (they're the tool for "alert on any finding matching X,
in any scan," which diff alerting can't express), narrow them hard — specific category +
module, or require_kev/epss_min — rather than a blanket severity_min=medium catch-all.findings_watchlist rule keeps re-firing on a triaged issue, the fix is upstream: mark the
fingerprint's lifecycle status in fleet (§9.1) and, if the rule is too broad to respect that
automatically, tighten the rule rather than muting the whole webhook.Every alert sender in this platform (monitor, chatter daemon — which literally imports and reuses
monitor.py's _post_webhook, so its outbox rows show up tagged kind="monitor", not
kind="chatter" — and findings_watchlist, tagged kind="watchlist") enqueues into a durable
outbox (alerts/store.py) instead of POSTing inline. The sender's own success/notified bookkeeping
reflects enqueue succeeding, not delivery. findings_watchlist._post_alert says so explicitly in
its docstring — "returns (True,'') on enqueue (queued = success for the caller's notified
bookkeeping)" — and monitor._post_webhook behaves identically (it enqueues and returns without
confirming delivery). To know whether a webhook
actually reached its destination, query the outbox itself — never infer it from the caller's log
line.
compute_backoff(attempts) = min(60 * 2**attempts, 21600) # base=60s, cap=21600s (6h)
Delivery attempts back off 120s → 240s → 480s → 960s → 1920s → 3840s → 7680s (the min(…, 21600)
6h cap is a theoretical ceiling the default max_attempts=8 never reaches — 7680s is the last backoff
actually scheduled). After 8 attempts (max_attempts, default), the row's status flips to dead — a literal dead-letter
state, visible in the outbox for manual retry, not silently dropped.
dedup_key = f"{kind}:" + sha1(url + json.dumps(payload, sort_keys=True))[:16]
enqueue() skips inserting a new row if a row with the same dedup_key is still pending —
duplicate-suppression only against not-yet-delivered duplicates, not against history (a genuinely
repeated event after the first one delivered will enqueue again, correctly).
alert_worker_loop runs every 30s (alert_worker_interval), delivers due rows, and — roughly
once per day — purges delivered rows older than 14 days (alert_retention_days) so the outbox
doesn't grow unbounded on an unattended box. Manual controls: retry(id) and retry_all_failed()
reset attempts to 0 and status back to pending (available from the dashboard's outbox page).
| Subsystem | Threshold knob | Default | What it gates |
|---|---|---|---|
monitor (CLI) |
--threshold |
medium |
New-finding severity floor; hot-asset types (§6.5) always fire regardless |
monitor_scheduler (dashboard job) |
job's alert_threshold field |
medium |
Same _maybe_alert() logic, per job |
chatter watch |
--threshold |
high |
Chatter-hit severity floor for the webhook |
findings_watchlist rule |
severity_min field, plus category/module/target_pattern/title_regex/require_kev/epss_min |
high |
Per-rule; narrow with the extra filters rather than relying on severity alone (§9.6) |
Start conservative (high-severity-only, plus the always-on hot-asset types) and widen only after confirming the loop is quiet at the tighter setting — it's far easier to notice "I'm not getting enough signal" and loosen a threshold than to diagnose alert fatigue after a client starts ignoring the channel.
A mature ASM implementation ships three native alternatives to a bare cron loop — prefer these where they fit before reaching for external scheduling:
asm-cli monitor <target> --interval N --webhook <url> --threshold <sev> — single-target
foreground daemon; does baseline + loop + diff + alert natively (§6).asm-cli chatter watch --webhook <url> --threshold high — the adversary-chatter daemon
(§7.7).A bare cron/Task-Scheduler loop is still the right call for: ad-hoc periodic reports without wanting a standing foreground process, wrapping scan+diff+report inside an existing ops pipeline, or a shared box where a long-running unsupervised Python daemon isn't operationally convenient.
# crontab -e (or drop a file under /etc/cron.d/)
# Every Monday 03:00 — re-scan, diff against last week's scan, write both Markdown + JSON.
0 3 * * 1 cd /opt/asm-cli && \
OLD_SCAN=$(ls -1t scans | grep '^acme_com_' | sed -n 1p) && \ # newest EXISTING dir = last run; captured before the new scan below is created
NEW_SCAN="acme_com_$(date +%Y%m%d)" && \
asm-cli scan acme.com --scan-id "$NEW_SCAN" --scans-root scans \
>> logs/weekly.log 2>&1 && \
asm-cli diff "$OLD_SCAN" "$NEW_SCAN" --scans-root scans \
--out "reports/diff_$(date +%F).md" --json "reports/diff_$(date +%F).json" \
>> logs/weekly.log 2>&1
Adjust --only/--exclude on the scan line per §6.4's cadence table if you want a cheap daily
stage-1/2-only cron alongside a heavier weekly full re-scan (two separate cron lines, two separate
scan-id prefixes).
# Register once (run as the service account that owns the asm-cli install):
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument `
'-NoProfile -ExecutionPolicy Bypass -File C:\asm-cli\weekly-diff.ps1'
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 3am
Register-ScheduledTask -TaskName "AsmWeeklyDiff" -Action $action -Trigger $trigger
# C:\asm-cli\weekly-diff.ps1
Set-Location C:\asm-cli
$old = (Get-ChildItem scans -Directory | Where-Object Name -like "acme_com_*" |
Sort-Object LastWriteTime -Descending | Select-Object -First 1).Name # newest EXISTING = last run; captured before $new is created
$new = "acme_com_$(Get-Date -Format yyyyMMdd)"
asm-cli scan acme.com --scan-id $new --scans-root scans *>> logs\weekly.log
asm-cli diff $old $new --scans-root scans `
--out "reports\diff_$(Get-Date -Format yyyy-MM-dd).md" `
--json "reports\diff_$(Get-Date -Format yyyy-MM-dd).json" *>> logs\weekly.log
All three alert senders (monitor, chatter, findings_watchlist) emit a Slack-compatible
payload — a text field (Slack Incoming Webhooks render it directly) plus a structured envelope any
generic JSON-accepting webhook handler can read. The monitor shape:
{
"text": ":rotating_light: *ASM monitor: delta on acme.com*\n_scan-A_ `acme_com_20260730` → _scan-B_ `acme_com_20260806`\n\n*NEW FINDINGS ≥ MEDIUM: 3*\n• `HIGH` — Exposed .git/config _(asset: webapp:dev.acme.com)_\n\nTotals: +4 assets, +3 findings, -1 resolved.",
"target": "acme.com",
"scan_a": {"id": "acme_com_20260730", "target": "acme.com", "time": "2026-07-30T03:00:04+00:00"},
"scan_b": {"id": "acme_com_20260806", "target": "acme.com", "time": "2026-08-06T03:00:11+00:00"},
"new_findings_total": 3,
"new_findings_hot": [
{"severity": "high", "title": "Exposed .git/config", "asset_key": "webapp:dev.acme.com"}
],
"new_assets_hot": [
{"type": "typosquat_domain", "value": "acrne.com"}
]
}
The chatter shape swaps the summary block for a single-hit envelope (severity, category,
classification, target, source, group_name, title, url, hit_id, first_seen_at,
posted_at); the findings_watchlist shape nests alert.rule and alert.finding (including
evidence, so a KEV/EPSS-gated rule's payload carries the CTI context that triggered it) instead of a
scan-pair summary. All three keep the plain-text text field first so a handler that ignores
everything else still renders something legible.
Drop these into a fresh session to verify the skill loads and routes correctly.
asm-cli monitor and the dashboard job scheduler?" → §6.2.ip asset, existence-only types don't track attrs).risk_accepted a month ago still shows up in every weekly re-scan. Is it counted in the FAIR risk grade?" → §9.1, §9.5 (yes — risk_accepted is not suppressed).risk_accepted. Should it re-alert the next time the monitor loop diffs a re-scan that still contains it?" → §9.6 (no, for monitor's fingerprint-diff alerting — same fingerprint isn't 'new'. But flag the caveat: a broad findings_watchlist rule with no category/module narrowing WILL re-fire, because it dedups on the per-scan finding_id, not the fingerprint.)open — a regression, not silently ignored).--validate so it re-runs credential testing every week automatically?" → §1, §6.2 (technically reachable via job flags — but treat it as a standing-authorization decision, not a cadence decision, and confirm sign-off first).findings_watchlist rules are
NOT lifecycle-aware while monitor's fingerprint-diff alerting structurally is, and what that
means for avoiding re-alerts on triaged findings (§9); the durable alert-outbox pattern including
the "queued is not delivered" distinction, backoff/dead-letter numbers, dedup key, and retention
(§10); and copy-paste bash-cron + PowerShell-Scheduled-Task recipes plus the Slack-compatible
webhook payload shape for all three alert senders (§11). Every number, endpoint, cadence, and
rule in this skill is transcribed from a production ASM implementation's shipped monitoring,
chatter, scan-diff, fleet, findings-watchlist, and alert-outbox modules — not invented.