Skip to main content
Any SafeDep Cloud table you can query with safedep query exec can drive an alert. The recipe stays the same for every destination:
  1. Write the SQL for the events you care about.
  2. Run safedep query exec -o json to get rows.
  3. Format the rows into your destination’s payload shape.
  4. POST to the webhook.
The rest of this page is a worked example: PMG block events, formatted as a Slack message, sent to a Slack Incoming Webhook, every 5 minutes. Everything below is a stand-in: swap the query, the formatter, or the curl target for your own destination.

Example: Slack alert for PMG blocks

Every 5 minutes, a scheduled job queries SafeDep Cloud for packages PMG blocked in the last 5 minutes and posts a Slack message like:
🛡️ PMG blocked 2 package(s)

🟡 COOLDOWN • [email protected] • npm
Endpoint: MacBook-Pro.local

🔴 MALICIOUS • [email protected] • PyPI
Endpoint: runnervmkkn4f
Both the package and endpoint are clickable: the package links to its SafeDep community report, the endpoint links to its page in SafeDep Cloud.

Prerequisites

You always need: For the Slack + Python example below, you also need: Confirm the CLI can reach SafeDep Cloud:
safedep auth status

Step 1: Write the query

Save the SQL to a file so you can version it in git and rerun it:
blocks.sql
SELECT
  package_guard_events.package_ecosystem,
  package_guard_events.package_name,
  package_guard_events.package_version,
  package_guard_events.package_action,
  package_guard_events.timestamp,
  endpoints.id,
  endpoints.identifier
FROM package_guard_events
JOIN endpoints ON endpoints.id = package_guard_events.invocation_id
WHERE package_guard_events.package_action IN (
  'PMG_PACKAGE_ACTION_BLOCKED',
  'PMG_PACKAGE_ACTION_COOLDOWN_BLOCKED'
)
ORDER BY package_guard_events.timestamp DESC
Two things to notice:
  • package_action filter. PMG_PACKAGE_ACTION_BLOCKED is a malicious-package block. PMG_PACKAGE_ACTION_COOLDOWN_BLOCKED is a cooldown-window block (packages held back until they age past your cooldown threshold).
  • JOIN endpoints. Each block event was produced by a PMG invocation on some developer machine or CI runner. Joining endpoints gives you the endpoint’s human-readable name (endpoints.identifier) and its ID (endpoints.id, used to build the Cloud deep-link). The ON clause is a required placeholder: SafeDep Cloud applies the real join from its catalog. See the SQL reference.
Run it once to see the shape of the data:
safedep query exec -o json --sql-file blocks.sql --limit 5
Enum columns (like package_action and package_ecosystem) return numeric ordinals in JSON; the formatter decodes them in Step 2. For the full schema, tables, and enum values, see the SafeDep Cloud SQL reference.

Step 2: Format events for Slack

Slack’s Incoming Webhook takes a JSON payload with a blocks array. The script below reads rows from stdin, decodes the enum ordinals, builds one Slack section block per event, and prints the payload on stdout. Save this as format.py:
format.py
#!/usr/bin/env python3
"""Read safedep query exec JSON on stdin, print a Slack payload on stdout."""
import json, sys
from urllib.parse import quote

ACTION = {1: ("🔴", "MALICIOUS"), 4: ("🟡", "COOLDOWN")}
ECOSYSTEM = {
    1: ("ECOSYSTEM_MAVEN",           "Maven"),
    2: ("ECOSYSTEM_NPM",             "npm"),
    3: ("ECOSYSTEM_PYPI",            "PyPI"),
    4: ("ECOSYSTEM_RUBYGEMS",        "RubyGems"),
    5: ("ECOSYSTEM_NUGET",           "NuGet"),
    6: ("ECOSYSTEM_CARGO",           "Cargo"),
    7: ("ECOSYSTEM_GO",              "Go"),
    8: ("ECOSYSTEM_GITHUB_ACTIONS",  "GitHub Actions"),
    9: ("ECOSYSTEM_PACKAGIST",       "Packagist"),
}

rows = json.load(sys.stdin).get("rows", [])
if not rows:
    sys.exit(0)  # empty stdout, curl skips the POST

def section(r):
    icon, reason         = ACTION[r["package_guard_events.package_action"]]
    eco_enum, eco_label  = ECOSYSTEM[r["package_guard_events.package_ecosystem"]]
    name, version        = r["package_guard_events.package_name"], r["package_guard_events.package_version"]
    ep_id, ep_name       = r["endpoints.id"], r["endpoints.identifier"]
    report   = f"https://app.safedep.io/community/packages/{eco_enum}/{quote(name, safe='')}/{quote(version, safe='')}"
    endpoint = f"https://app.safedep.io/endpoints/{ep_id}"
    text = (f"{icon} *{reason}* • <{report}|`{name}@{version}`> • {eco_label}"
            f"\n\nEndpoint: <{endpoint}|`{ep_name}`>")
    return {"type": "section", "text": {"type": "mrkdwn", "text": text}}

payload = {"blocks": [
    {"type": "header", "text": {"type": "plain_text", "text": f"🛡️ PMG blocked {len(rows)} package(s)"}},
    *[section(r) for r in rows],
]}
json.dump(payload, sys.stdout)
Add ecosystems you use to the ECOSYSTEM dict; nothing else needs to change.

Step 3: Post to Slack

Pipe the query into the formatter and the formatter into curl:
export SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'

safedep query exec -o json --sql-file blocks.sql --limit 10 \
  | python3 format.py \
  | curl -sS -X POST -H 'Content-Type: application/json' \
      --data @- "$SLACK_WEBHOOK_URL"
If there are no matching events, format.py exits with empty stdout and curl sends nothing. On a successful post, Slack replies with ok.
Slack caps a message at 50 blocks, so keep --limit around 10. See Slack’s block limits.

Step 4: Run it on a schedule

To make this an alerting pipeline, filter to a rolling window and run periodically. The window and the schedule interval must match so events aren’t dropped or duplicated. Save this as safedep-alerts.sh next to format.py. It’s the same query as blocks.sql, plus one extra AND clause for the time window, an explicit PATH (schedulers run with a minimal PATH), and a skip when the window has no events (avoids Slack’s invalid_payload on empty POSTs).
safedep-alerts.sh
#!/usr/bin/env bash
set -euo pipefail

# Schedulers run with a minimal PATH. Add the location of `safedep` and `python3`.
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"

cd "$(dirname "$0")"

# macOS / BSD date. On Linux: SINCE=$(date -u -d '5 minutes ago' '+%Y-%m-%dT%H:%M:%SZ')
SINCE=$(date -u -v-5M '+%Y-%m-%dT%H:%M:%SZ')

PAYLOAD=$(safedep query exec -o json --limit 10 --sql "
  SELECT package_guard_events.package_ecosystem,
         package_guard_events.package_name,
         package_guard_events.package_version,
         package_guard_events.package_action,
         package_guard_events.timestamp,
         endpoints.id,
         endpoints.identifier
  FROM package_guard_events
  JOIN endpoints ON endpoints.id = package_guard_events.invocation_id
  WHERE package_guard_events.package_action IN (
    'PMG_PACKAGE_ACTION_BLOCKED',
    'PMG_PACKAGE_ACTION_COOLDOWN_BLOCKED'
  ) AND package_guard_events.timestamp > '$SINCE'
  ORDER BY package_guard_events.timestamp DESC
" | python3 format.py)

[ -n "$PAYLOAD" ] && printf '%s' "$PAYLOAD" | curl -sS -X POST \
  -H 'Content-Type: application/json' --data @- "$SLACK_WEBHOOK_URL"
Then wire it up with your OS’s native scheduler:
On macOS, cron can’t reach the keychain where safedep auth login stores its OAuth token, so scheduled safedep query exec calls fail with not authenticated. Use a launchd user agent instead: it runs in your logged-in user session and inherits keychain access.Save this as ~/Library/LaunchAgents/io.safedep.alerts.plist:
io.safedep.alerts.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>io.safedep.alerts</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/you/safedep-alerts/safedep-alerts.sh</string>
  </array>

  <key>StartInterval</key>
  <integer>300</integer>

  <key>EnvironmentVariables</key>
  <dict>
    <key>SLACK_WEBHOOK_URL</key>
    <string>PASTE_YOUR_SLACK_WEBHOOK_URL_HERE</string>
  </dict>

  <key>StandardOutPath</key>
  <string>/tmp/safedep-alerts.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/safedep-alerts.log</string>

  <key>RunAtLoad</key>
  <true/>
</dict>
</plist>
Load, verify, and tail the log:
launchctl load ~/Library/LaunchAgents/io.safedep.alerts.plist
launchctl list | grep io.safedep.alerts
tail -f /tmp/safedep-alerts.log
RunAtLoad fires the job immediately. StartInterval is in seconds (300 = 5 minutes) and must match the -v-5M window in the script.To stop it:
launchctl unload ~/Library/LaunchAgents/io.safedep.alerts.plist
The design is stateless: no cursor file to keep in sync, no drift across restarts. --limit 10 is set for Slack’s 50-block ceiling. Events beyond the limit in a single window are silently dropped. For burstier traffic, raise --limit (the CLI allows up to 100) and page through the JSON next_page_token, sending one Slack message per page.

Send to other destinations

Only the last two things change: the payload shape (in format.py) and the URL (in curl). Any JSON-accepting HTTP endpoint works.

Discord

Emit a simple content payload and POST to your channel’s webhook URL.

Microsoft Teams

Create a channel Workflow with a webhook trigger and POST an Adaptive Card payload to its URL.

PagerDuty

Emit an Events API v2 payload and POST to the enqueue endpoint.

Query other events

Swap blocks.sql for any question you can ask SafeDep Cloud:
  • Insecure bypasses: filter package_guard_events on event_type = 'PMG_EVENT_TYPE_INSECURE_BYPASS'.
  • Endpoint activity: JOIN endpoints and group by endpoints.identifier to see which machines produced the most events.
  • Malicious packages across projects: query component_malicious_packages with is_verified = true.
See the SafeDep Cloud SQL guide for the full schema, query syntax, and the paging model.