Receiving scan result

Scan results are published to the an SNS topic -called findings topic- and EventBridge. Additionally, we tag the scanned object with the scan result. The following examples demonstrate how you can use the scan result in your applications.

Example: Generate a pre-signed URL for download if the file is tagged as clean

The following example queries the object’s tags and extracts the scan result before generating a pre-signed URL for download.

Run npm i aws-sdk to install the required dependency.

const AWS = require('aws-sdk');
const s3 = new AWS.S3({ apiVersion: '2006-03-01' });

function getSignedUrl (bucket, key, cb) {
  s3.getObjectTagging({
    Bucket: bucket,
    Key: key
  }, (err, data) => {
    if (err) {
      cb(err);
    } else {
      const tag = data.TagSet.find(tag => tag.Key === 'bucketav');
      if (tag !== undefined && tag.Value === 'clean') {
        s3.getSignedUrl('getObject', {
          Bucket: bucket,
          Key: key
        }, (err, signedUrl) => {
          if (err) {
            cb(err);
          } else {
            cb(null, signedUrl);
          }
        });
      } else {
        cb(null, null);
      }
    }
  });
}

getSignedUrl('your-bucket', 'path/to/file.pdf', (err, signedUrl) => {
  if (err) {
    console.error('something went wrong', err);
  } else {
    if (signedUrl === null) {
      console.log('download not possible (file infected, unscannable, or not yet scanned)');
    } else {
      console.log(signedUrl);
    }
  }
});

GitHub: https://github.com/widdix/bucketav-developer-examples/blob/main/javascript/pre-signed-download-based-on-tag.js

Run pip install boto3 to install the required dependency.

import boto3

s3 = boto3.client('s3')

def get_signed_url(bucket, key):
    response = s3.get_object_tagging(
        Bucket=bucket,
        Key=key
    )

    tags = response['TagSet']
    tag = next((t for t in tags if t['Key'] == 'bucketav'), None)

    if tag is not None and tag['Value'] == 'clean':
        signed_url = s3.generate_presigned_url(
            'get_object',
            Params={
                'Bucket': bucket,
                'Key': key
            }
        )
        return signed_url
    else:
        return None

signed_url = get_signed_url('your-bucket', 'path/to/file.pdf')

if signed_url is None:
    print('Download not possible (file infected, unscannable, or not yet scanned)')
else:
    print(signed_url)

GitHub: https://github.com/widdix/bucketav-developer-examples/blob/main/python/pre-signed-download-based-on-tag.py

Example: Subscribe to Findings Topic and store results in DynamoDB

The following example:

  1. Subscribes to the Findings Topic
  2. Stores scan results in DynamoDB
  3. Queries DynamoDB and extracts the scan result before generating a pre-signed URL for download.

Please find the example on GitHub: https://github.com/widdix/bucketav-developer-examples/tree/main/javascript/subscribe-to-findings-topic

SNS message format

The SNS topic name is prefixed by the CloudFormation stack name you defined during setup (if you followed the docs, the prefix is bucketav).

If the subscription protocol is set to email, the body contains a human-readable string. Otherwise, the body is formatted in JSON with the following keys/structure.

JSON payloads require bucketAV for Amazon S3 powered by ClamAV® version >= 2.1.0 or bucketAV for Amazon S3 powered by Sophos® version >= 2.0.0, or any version of bucketAV for Cloudflare R2. To update to the latest version, follow the Update Guide.

  • bucket (string): The bucket name.
  • key (string): The object key.
  • version (string, optional): If versioning is turned on, the object version (requires bucketAV powered by ClamAV® version >= 2.3.0 or bucketAV powered by Sophos® version >= 2.0.0).
  • size (number): The object size in bytes (requires bucketAV powered by ClamAV® version >= 2.13.0 or bucketAV powered by Sophos® version >= 2.2.0).
  • status (string (clean, infected, no)): The scan result.
  • action (string (delete, tag, no)): The action that was taken.
  • finding (string, optional): For infected files, the type of virus/malware that was detected.
  • trace_id (string, optional): ID to trace custom scan jobs (requires bucketAV powered by ClamAV® version >= 2.9.0 or bucketAV powered by Sophos® version >= 2.0.0).
  • custom_data (string, optional): Custom data defined when submitting a custom scan job (requires bucketAV powered by ClamAV® version >= 2.9.0 or bucketAV powered by Sophos® version >= 2.0.0).
  • realfiletype (string, optional): The Real File Type detected by the Sophos engine (Sophos only, requires bucketAV powered by Sophos® version >= 2.18.0 or bucketAV for Cloudflare R2 powered by Sophos® >= 2.5.0).
{
  "bucket": "testbucket",
  "key": "bucketav/test/file.pdf",
  "version": "optional_qAKVCwsELNe_pDHtdIIzX_jRJAcKVQaG",
  "status": "infected",
  "action": "delete",
  "finding": "optional_TestWorm",
  "trace_id": "1234567",  
  "custom_data": "...",
  "realfiletype": "Adobe Portable Document Format (PDF)"
}

The following message attributes are part of every message and can be used for message filtering:

SNS attribute values must not contain surrogate characters (e.g., some emojis). Therefore, we replace them with the # character. Inspect the JSON body to get the raw data.

  • bucket (string): The bucket name.
  • key (string): The object key
  • version (string, optional): If versioning is turned on, the object version.
  • size (number): The object size in bytes (requires bucketAV powered by ClamAV® version >= 2.13.0 or bucketAV powered by Sophos® version >= 2.2.0).
  • status (string (clean, infected, no)): The scan result.
  • action (string (delete, tag, no)): The action that was taken.
  • finding (string, optional): For infected files, the type of virus/malware that was detected (requires bucketAV powered by ClamAV® version >= 1.7.0 or bucketAV powered by Sophos® version >= 2.0.0).
  • trace_id (string, optiona): ID to trace custom scan jobs (requires bucketAV powered by ClamAV® version >= 2.9.0 or bucketAV powered by Sophos® version >= 2.0.0).

EventBridge message format

To emit events to EventBridge, set the ReportEventBridge configuration parameter to true. The source of events is com.bucketav and the type is Scan Result. The event body contains the following fields.

  • bucket (string): The bucket name.
  • key (string): The object key.
  • version (string, optional): If versioning is turned on, the object version.
  • size (number): The object size in bytes.
  • status (string (clean, infected, no)): The scan result.
  • action (string (delete, tag, no)): The action that was taken.
  • finding (string, optional): For infected files, the type of virus/malware that was detected.
  • trace_id (string, optional): ID to trace custom scan jobs.
  • custom_data (string, optional): Custom data defined when submitting a custom scan job.
  • realfiletype (string, optional): The Real File Type detected by the Sophos engine (Sophos only, requires bucketAV powered by Sophos® version >= 2.18.0 or bucketAV for Cloudflare R2 powered by Sophos® >= 2.5.0).
{
  "version": "0",
  "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "detail-type": "Scan Result",
  "source": "com.bucketav",
  "account": "111111111111",
  "time": "2024-10-22T00:00:00Z",
  "region": "eu-west-1",
  "resources": [],
  "detail": {
    "bucket": "testbucket",
    "key": "bucketav/test/file.pdf",
    "version": "optional_qAKVCwsELNe_pDHtdIIzX_jRJAcKVQaG",
    "status": "infected",
    "action": "delete",
    "finding": "optional_TestWorm",
    "trace_id": "1234567",  
    "custom_data": "...",
    "realfiletype": "Adobe Portable Document Format (PDF)"
  }
}

Callback

A callback URL can be defined when sending a scan job. The callback URL defined in downloads.callback_url is called by bucketAV via an HTTPS POST request with a JSON paylod with this properties:

  • status (string (clean, infected, no)): The scan result.
  • finding (string, optional): For infected files, the type of virus/malware that was detected.
  • size (number): The file size in bytes.
  • download_time (string): Time to download the file in seconds.
  • scan_time (string): Time to scan the file in seconds.
  • download_url (string): The downloaded URL.
  • trace_id (string, optional): ID to trace custom scan jobs.
  • custom_data (string, optional): Custom data defined when submitting a custom scan job.
  • realfiletype (string, optional): The Real File Type detected by the Sophos engine (Sophos only, requires bucketAV powered by Sophos® version >= 2.18.0).

For security reasons, we recommend verifying callbacks to ensure that the callback was made by your bucketAV installation.

Verify

To sign callbacks, set the SignCallbackInvocations configuration parameter to true.

bucketAV creates a random, asymmetric key pair for you. The public key is available via AWS Systems Manager Parameter Store. The parameter names are (replace ${STACK_NAME} with the CloudFormation stack name of bucketAV; if you followed the docs, the name is bucketav):

  • /bucketAV/${STACK_NAME}/PublicKeyPEMPKCS1: The public key in PEM PKCS1 format
  • /bucketAV/${STACK_NAME}/PublicKeyPEMX509: The public key in PEM X509 format

The following headers are added to callback invocations:

  • X-Signature: RSA-SHA25 signature hex encoded.
  • X-Timestamp: Unix time in milliseconds.

To verify the signature:

  1. Get public key from AWS Systems Manager Parameter Store.
  2. Parse public key.
  3. Construct verification string: ${timestamp}.${callbackUrl}.${body}.
    1. Replace ${timestamp} with the X-Timestamp header value.
    2. Replace ${callbackUrl} with the value you used for callback_url when sending the scan job.
    3. Replace ${body} with the raw callback body.
  4. Initialize RSA-SHA256 verifier with public key.
  5. Verify that the verification string produces the same signature as the X-Signature header value.
  6. Validate the timestamp is within an acceptable window (we recommend ± 5 minutes) to prevent replay attacks.

The following snippets implement the logic described above.

package com.bucketav;

import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.security.KeyFactory;
import java.nio.charset.StandardCharsets;
import javax.xml.bind.DatatypeConverter;

public class CallbackSignatureX509 {

    private static final long SIGNATURE_TOLERANCE_IN_MILLIS = 5 * 60 * 1000; // 5 minutes

    /**
     * Verifies the signature of a callback.
     *
     * @param unixtimeInMillis Current unixtime in milliseconds (System.currentTimeMillis())
     * @param publicKeyPEM     The public key in PEM X509 format (available via SSM parameter /bucketAV/STACK_NAME/PublicKeyPEMX509)
     * @param timestamp        The value of the X-Timestamp header of the callback
     * @param callbackUrl      The callback URL used when submitting the scan job
     * @param body             The body of the callback
     * @param signature        The value of the X-Signature header of the callback
     * @return true if the callback is valid
     * @throws NoSuchAlgorithmException
     * @throws InvalidKeySpecException
     * @throws InvalidKeyException
     * @throws SignatureException
     */
    static boolean verify(final long unixtimeInMillis, final String publicKeyPEM, final String timestamp, final String callbackUrl, final String body, final String signature) throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidKeyException, SignatureException {
        final Signature verifier = Signature.getInstance("SHA256withRSA");
        final PublicKey pubKey = parsePublicKey(publicKeyPEM);
        verifier.initVerify(pubKey);
        final String dataToVerify;
        verifier.update(dataToVerify.getBytes(StandardCharsets.UTF_8));
        final byte[] signatureBytes = DatatypeConverter.parseHexBinary(signature);
        final boolean valid = verifier.verify(signatureBytes);
        final boolean withinTolerance = Math.abs(unixtimeInMillis - Long.parseLong(timestamp)) <= SIGNATURE_TOLERANCE_IN_MILLIS;
        return valid && withinTolerance;
    }

    private static PublicKey parsePublicKey(final String publicKeyPEM) throws NoSuchAlgorithmException, InvalidKeySpecException {
        final String cleanKey = publicKeyPEM
                .replace("-----BEGIN PUBLIC KEY-----", "")
                .replace("-----END PUBLIC KEY-----", "")
                .replaceAll("\\s", "");
        final byte[] keyBytes = DatatypeConverter.parseBase64Binary(cleanKey);
        final X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
        final KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        return keyFactory.generatePublic(spec);
    }
}
import {createPublicKey, createVerify} from 'node:crypto';

const SIGNATURE_TOLERANCE_IN_MILLIS = 5 * 60 * 1000;

/**
 * Verifies the signature of a callback.
 * 
 * @param {number} unixtimeInMillis - Current unixtime in milliseconds (Date.now())
 * @param {string} publicKeyPEM -  The public key in PEM PKCS1 format (available via SSM parameter /bucketAV/${STACK_NAME}/PublicKeyPEMPKCS1)
 * @param {string} timestamp - The value of the X-Timestamp header of the callback
 * @param {string} callbackUrl - The callback URL used when submitting the scan job
 * @param {string} body - The body of the callback
 * @param {string} signature - The value of the X-Signature header of the callback
 * @return {boolean} - true if the callback is valid
 */
function validateSignature(unixtimeInMillis, publicKeyPEM, timestamp, callbackUrl, body, signature) {
  const publicKey = createPublicKey({
    key: publicKeyPEM,
    format: 'pem',
    type: 'pkcs1'
  });
  const verify = createVerify('sha256');
  verify.update(timestamp);
  verify.update('.');
  verify.update(callbackUrl);
  verify.update('.');
  verify.update(body);
  verify.end();
  const valid = verify.verify(publicKey, signature, 'hex');
  return valid && Math.abs(unixtimeInMillis-parseInt(timestamp, 10)) <= SIGNATURE_TOLERANCE_IN_MILLIS;
}

Need more help?

Write us, and we'll get back to you as soon as we can.

Send us an email