Skip to content
Please update to the latest release 0.77.3 to address Multiple CVEs.
Server.Enrichment.ScanMalware

Server.Enrichment.ScanMalware

Look up a domain, hostname, IPv4 address or URL in the public archive of ScanMalware, a free sandbox that renders URLs in a browser and keeps its public scans searchable. Returns the verdict and risk score of the newest completed scan of that host or URL, with a link to the full report. No account, API key or secret is needed, and nothing is submitted: the artifact only reads scans that already exist.

Phishing pages often redirect to the brand they imitate, so the archive lists a lure hosted on pages.dev under facebook.com when that is where the lure ended. On shared platforms such as github.io, a subdomain is usually an unrelated tenant. A verdict is therefore attributed to the indicator only when the scan was submitted for that exact host or its www name. Scans that only redirected to the host are counted in RedirectedHere and scans of subdomains in SubdomainScans; neither lends its verdict.

For a URL, a scan of that exact URL (ignoring a trailing slash) is preferred and Match is url. Otherwise the newest scan of the same host is returned and Match is host, because a scan of a home page is not a verdict on a login page of the same site. When no scan of the host exists the row has an empty Verdict: unknown is not the same as clean.

Run from a server notebook, for example:

SELECT * FROM Artifact.Server.Enrichment.ScanMalware(
  Indicator="example.com")

To enrich hunt results, deduplicate first and pace the lookups:

SELECT * FROM foreach(
  row={SELECT Domain FROM source() GROUP BY Domain},
  query={SELECT * FROM Artifact.Server.Enrichment.ScanMalware(
    Indicator=Domain) WHERE sleep(ms=300)})

Each indicator costs one request for a host or two for a URL, plus one for the verdict when a scan is found. The anonymous API allows 600 requests per minute per IP address. An HTTP 429 is reported in Error rather than retried. Requests carry the User-Agent scanmalware-velociraptor/1.0.


name: Server.Enrichment.ScanMalware
author: Jonas Lejon, ScanMalware
description: |
  Look up a domain, hostname, IPv4 address or URL in the public archive of
  ScanMalware, a free sandbox that renders URLs in a browser and keeps its
  public scans searchable. Returns the verdict and risk score of the newest
  completed scan of that host or URL, with a link to the full report. No
  account, API key or secret is needed, and nothing is submitted: the
  artifact only reads scans that already exist.

  Phishing pages often redirect to the brand they imitate, so the archive
  lists a lure hosted on `pages.dev` under `facebook.com` when that is where
  the lure ended. On shared platforms such as `github.io`, a subdomain is
  usually an unrelated tenant. A verdict is therefore attributed to the
  indicator only when the scan was submitted for that exact host or its
  `www` name. Scans that only redirected to the host are counted in
  RedirectedHere and scans of subdomains in SubdomainScans; neither lends
  its verdict.

  For a URL, a scan of that exact URL (ignoring a trailing slash) is
  preferred and Match is `url`. Otherwise the newest scan of the same host
  is returned and Match is `host`, because a scan of a home page is not a
  verdict on a login page of the same site. When no scan of the host exists
  the row has an empty Verdict: unknown is not the same as clean.

  Run from a server notebook, for example:

      SELECT * FROM Artifact.Server.Enrichment.ScanMalware(
        Indicator="example.com")

  To enrich hunt results, deduplicate first and pace the lookups:

      SELECT * FROM foreach(
        row={SELECT Domain FROM source() GROUP BY Domain},
        query={SELECT * FROM Artifact.Server.Enrichment.ScanMalware(
          Indicator=Domain) WHERE sleep(ms=300)})

  Each indicator costs one request for a host or two for a URL, plus one
  for the verdict when a scan is found. The anonymous API allows 600
  requests per minute per IP address. An HTTP 429 is reported in Error
  rather than retried. Requests carry the User-Agent
  `scanmalware-velociraptor/1.0`.

reference:
  - https://scanmalware.com/api-docs

type: SERVER

parameters:
  - name: Indicator
    type: string
    description: Domain, hostname, IPv4 address or http(s) URL to look up.
  - name: ScanWindow
    type: int
    default: 100
    description: How many of the newest scans of the host to consider, 1 to 100.

sources:
  - query: |
      LET Base <= "https://scanmalware.com"
      LET UA <= "scanmalware-velociraptor/1.0"

      LET Value <= strip(string=Indicator)
      LET IsURL <= Value =~ "^(?i)https?://"
      LET Host <= lowcase(string=strip(suffix=".",
          string=if(condition=IsURL, then=url(parse=Value).Hostname, else=Value)))
      LET ValidHost <= Host =~ "^[a-z0-9_-]+(\\.[a-z0-9_-]+)+$"

      // The url filter matches the submitted and the final URL as substrings,
      // so only a row submitted as this exact URL is a scan of it.
      LET ExactResponse <= SELECT Response, parse_json(data=Content) AS Body
        FROM if(condition=IsURL AND ValidHost, then={
          SELECT * FROM http_client(
            url=Base + "/api/v1/search/smql",
            params=dict(q="url:" + Value, `limit`=100),
            user_agent=UA)
        })

      LET ExactScans <= SELECT scan_id, url, final_url, completed_at
        FROM foreach(row=ExactResponse[0].Body.results)
        WHERE status = "completed"
          AND strip(string=url, suffix="/") = strip(string=Value, suffix="/")
        LIMIT 1

      // Lists scans submitted for the host, scans of its subdomains, and scans
      // of other sites that redirected here, newest first.
      LET HostResponse <= SELECT Response, parse_json_array(data=Content) AS Body
        FROM if(condition=ValidHost, then={
          SELECT * FROM http_client(
            url=Base + "/api/v1/domains/" + Host + "/scans",
            params=dict(`limit`=ScanWindow, status="completed"),
            user_agent=UA)
        })

      LET HostRows <= SELECT scan_id, url, final_url, completed_at,
          "url" in matched_on AS Submitted,
          lowcase(string=url(parse=url).Hostname) AS ScanHost
        FROM foreach(row=HostResponse[0].Body)

      LET OwnScans <= SELECT * FROM HostRows
        WHERE Submitted AND ScanHost in (Host, "www." + Host)

      LET SubdomainRows <= SELECT * FROM HostRows
        WHERE Submitted AND NOT ScanHost in (Host, "www." + Host)

      LET RedirectRows <= SELECT * FROM HostRows WHERE NOT Submitted

      LET Chosen <= if(condition=ExactScans, then=ExactScans[0],
                       else=OwnScans[0])

      LET SummaryResponse <= SELECT Response, parse_json(data=Content) AS Body
        FROM if(condition=Chosen.scan_id =~ "^[0-9a-f-]{36}$", then={
          SELECT * FROM http_client(
            url=Base + "/api/v1/scan/" + Chosen.scan_id + "/summary",
            user_agent=UA)
        })

      LET Summary <= SummaryResponse[0].Body

      LET Failed <= SELECT Response FROM chain(
          a=ExactResponse, b=HostResponse, c=SummaryResponse)
        WHERE Response != 200
        LIMIT 1

      // An empty list parses to [], an unreadable body to NULL.
      LET Unreadable <= SELECT Response FROM chain(
          a=ExactResponse, b=HostResponse, c=SummaryResponse)
        WHERE Response = 200 AND Body = NULL
        LIMIT 1

      SELECT Indicator,
          if(condition=ExactScans, then="url",
             else=if(condition=OwnScans, then="host")) AS Match,
          Summary.security_verdict.verdict AS Verdict,
          Summary.risk_score AS RiskScore,
          Summary.security_verdict.risk_level AS RiskLevel,
          Summary.security_verdict.confidence AS Confidence,
          Summary.security_verdict.risk_factors AS RiskFactors,
          Chosen.url AS ScannedURL,
          Chosen.final_url AS FinalURL,
          Chosen.completed_at AS ScannedAt,
          if(condition=Chosen.scan_id,
             then=Base + "/result/" + Chosen.scan_id) AS ReportURL,
          len(list=OwnScans) AS HostScans,
          len(list=SubdomainRows) AS SubdomainScans,
          len(list=RedirectRows) AS RedirectedHere,
          if(condition=NOT ValidHost,
             then="Indicator is not a hostname, IPv4 address or http(s) URL.",
             else=if(condition=Failed[0].Response = 429,
               then="ScanMalware rate limit reached (600 requests per minute); try again later.",
               else=if(condition=Failed,
                 then=format(format="ScanMalware returned HTTP %v.",
                             args=Failed[0].Response),
                 else=if(condition=Unreadable,
                   then="ScanMalware returned an unreadable response.",
                   else=if(condition=Chosen.scan_id AND NOT Summary.security_verdict.verdict,
                     then="The newest scan has no verdict yet; run again in a few minutes.")))))
            AS Error,
          Summary AS _Summary
      FROM scope()
      WHERE Value