Files
prowler/notebooks/image_provider_api_test.ipynb
T
Andoni A. 348808b37f feat(image): add image provider scan support to Scan class and API layer
- Add image provider handling in Scan class alongside IaC (skip check loading, run Trivy directly)
- Add image provider API models, serializers, and connection check logic
- Point API SDK dependency to this feature branch
- Add test notebook for image provider API workflow
2026-02-18 08:59:13 +01:00

42 KiB

Image Provider API Testing

This notebook tests the Image Provider API endpoints added in PROWLER-940.

Prerequisites:

  • API running on localhost:8080 (make build-and-run-api-dev)
  • Dev fixtures loaded (automatic on startup)

Workflow tested:

  1. Authentication
  2. Create image provider
  3. Create provider secret (multiple auth methods)
  4. Test provider connection
  5. Trigger a scan
  6. Monitor scan progress
  7. Retrieve findings
  8. Cleanup

Setup

In [1]:
import getpass
import json
import time

import requests

BASE_URL = "http://localhost:8080/api/v1"
CONTENT_TYPE = "application/vnd.api+json"
HEADERS = {"Content-Type": CONTENT_TYPE}

# Dev user credentials
EMAIL = "dev@prowler.com"
PASSWORD = "Thisisapassword123@"


def pp(response):
    """Pretty-print a response with status code."""
    print(f"HTTP {response.status_code}")
    try:
        print(json.dumps(response.json(), indent=2))
    except Exception:
        print(response.text[:500])
    return response

1. Authentication

Obtain a JWT access token using the dev user credentials.

In [2]:
auth_resp = requests.post(
    f"{BASE_URL}/tokens",
    headers=HEADERS,
    json={
        "data": {
            "type": "tokens",
            "attributes": {"email": EMAIL, "password": PASSWORD},
        }
    },
)
assert auth_resp.status_code == 200, f"Auth failed: {auth_resp.text}"

tokens = auth_resp.json()["data"]["attributes"]
AUTH_HEADERS = {
    **HEADERS,
    "Authorization": f"Bearer {tokens['access']}",
}

print("Authenticated successfully.")
print(f"Access token: {tokens['access'][:50]}...")

# Cleanup: delete any pre-existing image providers to make notebook idempotent
_existing = requests.get(
    f"{BASE_URL}/providers",
    headers=AUTH_HEADERS,
    params={"filter[provider]": "image", "page[size]": 100},
)
_existing_providers = _existing.json().get("data", [])
if _existing_providers:
    print(f"\nCleaning up {len(_existing_providers)} pre-existing image provider(s)...")
    for _p in _existing_providers:
        requests.delete(f"{BASE_URL}/providers/{_p['id']}", headers=AUTH_HEADERS)
    time.sleep(2)  # Wait for async deletes
    print("Done.")
Authenticated successfully.
Access token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJhY...

Cleaning up 3 pre-existing image provider(s)...
Done.
In [3]:
print("=== DockerHub credentials (docker.io/andoniaf) ===")
dockerhub_username = "andoniaf"
dockerhub_password = getpass.getpass(f"Password/Token for {dockerhub_username}: ")

print("\n=== ECR credentials (714274078102.dkr.ecr.eu-west-1.amazonaws.com) ===")
print("Tip: run `aws ecr get-login-password --region eu-west-1` to get the password")
ecr_username = "AWS"
ecr_password = getpass.getpass(f"ECR password for {ecr_username}: ")
=== DockerHub credentials (docker.io/andoniaf) ===

=== ECR credentials (714274078102.dkr.ecr.eu-west-1.amazonaws.com) ===
Tip: run `aws ecr get-login-password --region eu-west-1` to get the password

2. Create Image Provider

Create a new image provider with a registry URL as its UID.

In [4]:
provider_resp = pp(
    requests.post(
        f"{BASE_URL}/providers",
        headers=AUTH_HEADERS,
        json={
            "data": {
                "type": "providers",
                "attributes": {
                    "provider": "image",
                    "uid": "ghcr.io",
                    "alias": "GitHub Container Registry",
                },
            }
        },
    )
)

assert provider_resp.status_code == 201, f"Provider creation failed: {provider_resp.text}"
provider_id = provider_resp.json()["data"]["id"]
print(f"\nProvider ID: {provider_id}")
HTTP 201
{
  "data": {
    "type": "providers",
    "id": "1a00fe5f-f137-4a63-81f1-8ac7ce0a57ed",
    "attributes": {
      "alias": "GitHub Container Registry",
      "provider": "image",
      "uid": "ghcr.io"
    }
  },
  "meta": {
    "version": "v1"
  }
}

Provider ID: 1a00fe5f-f137-4a63-81f1-8ac7ce0a57ed

2a. Verify Different Registry URL Formats

Test that various valid registry URLs are accepted.

In [5]:
valid_uids = [
	("714274078102.dkr.ecr.eu-west-1.amazonaws.com", "AWS ECR"),
    ("docker.io/andoniaf", "Docker Hub"),
]

created_ids = [provider_id]

for uid, alias in valid_uids:
    resp = requests.post(
        f"{BASE_URL}/providers",
        headers=AUTH_HEADERS,
        json={
            "data": {
                "type": "providers",
                "attributes": {
                    "provider": "image",
                    "uid": uid,
                    "alias": alias,
                },
            }
        },
    )
    status_icon = "\u2705" if resp.status_code == 201 else "\u274c"
    print(f"{status_icon} {uid:55s} -> HTTP {resp.status_code}")
    if resp.status_code == 201:
        created_ids.append(resp.json()["data"]["id"])

print(f"\nTotal created_ids: {len(created_ids)}")
✅ 714274078102.dkr.ecr.eu-west-1.amazonaws.com            -> HTTP 201
✅ docker.io/andoniaf                                      -> HTTP 201

Total created_ids: 3

2b. Verify Invalid UIDs Are Rejected

In [6]:
invalid_uids = [
    ("ab", "too short (min_length=3)"),
    ("not valid!", "contains space and exclamation mark"),
]

for uid, reason in invalid_uids:
    resp = requests.post(
        f"{BASE_URL}/providers",
        headers=AUTH_HEADERS,
        json={
            "data": {
                "type": "providers",
                "attributes": {
                    "provider": "image",
                    "uid": uid,
                    "alias": "test",
                },
            }
        },
    )
    status_icon = "\u2705" if resp.status_code == 400 else "\u274c"
    error_code = resp.json()["errors"][0]["code"] if resp.status_code == 400 else "N/A"
    print(f"{status_icon} '{uid}' ({reason}) -> HTTP {resp.status_code}, code={error_code}")
✅ 'ab' (too short (min_length=3)) -> HTTP 400, code=min_length
✅ 'not valid!' (contains space and exclamation mark) -> HTTP 400, code=image-uid

2c. List Providers (filter by image type)

In [7]:
resp = pp(
    requests.get(
        f"{BASE_URL}/providers",
        headers=AUTH_HEADERS,
        params={"filter[provider]": "image"},
    )
)
print(f"\nTotal image providers: {resp.json()['meta']['pagination']['count']}")
HTTP 200
{
  "links": {
    "first": "http://localhost:8080/api/v1/providers?filter%5Bprovider%5D=image&page%5Bnumber%5D=1",
    "last": "http://localhost:8080/api/v1/providers?filter%5Bprovider%5D=image&page%5Bnumber%5D=1",
    "next": null,
    "prev": null
  },
  "data": [
    {
      "type": "providers",
      "id": "d5ed4973-0b4e-4722-974f-bb05b99e95e7",
      "attributes": {
        "inserted_at": "2026-02-17T17:22:19.173707Z",
        "updated_at": "2026-02-17T17:22:19.173716Z",
        "provider": "image",
        "uid": "docker.io/andoniaf",
        "alias": "Docker Hub",
        "connection": {
          "connected": null,
          "last_checked_at": null
        }
      },
      "relationships": {
        "secret": {
          "data": null
        },
        "provider_groups": {
          "meta": {
            "count": 0
          },
          "data": []
        }
      },
      "links": {
        "self": "http://localhost:8080/api/v1/providers/d5ed4973-0b4e-4722-974f-bb05b99e95e7"
      }
    },
    {
      "type": "providers",
      "id": "7f93cb59-6e2a-471a-95af-8bfccfad57d6",
      "attributes": {
        "inserted_at": "2026-02-17T17:22:19.122114Z",
        "updated_at": "2026-02-17T17:22:19.122129Z",
        "provider": "image",
        "uid": "714274078102.dkr.ecr.eu-west-1.amazonaws.com",
        "alias": "AWS ECR",
        "connection": {
          "connected": null,
          "last_checked_at": null
        }
      },
      "relationships": {
        "secret": {
          "data": null
        },
        "provider_groups": {
          "meta": {
            "count": 0
          },
          "data": []
        }
      },
      "links": {
        "self": "http://localhost:8080/api/v1/providers/7f93cb59-6e2a-471a-95af-8bfccfad57d6"
      }
    },
    {
      "type": "providers",
      "id": "1a00fe5f-f137-4a63-81f1-8ac7ce0a57ed",
      "attributes": {
        "inserted_at": "2026-02-17T17:22:15.656726Z",
        "updated_at": "2026-02-17T17:22:15.656764Z",
        "provider": "image",
        "uid": "ghcr.io",
        "alias": "GitHub Container Registry",
        "connection": {
          "connected": null,
          "last_checked_at": null
        }
      },
      "relationships": {
        "secret": {
          "data": null
        },
        "provider_groups": {
          "meta": {
            "count": 0
          },
          "data": []
        }
      },
      "links": {
        "self": "http://localhost:8080/api/v1/providers/1a00fe5f-f137-4a63-81f1-8ac7ce0a57ed"
      }
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pages": 1,
      "count": 3
    },
    "version": "v1"
  }
}

Total image providers: 3

3. Create Provider Secret

The image provider supports multiple auth methods:

  • Docker login: registry_username + registry_password
  • Registry token: registry_token (bearer token)
  • No auth: empty secret {} (for public registries)

Optional scan filters: image_filter, tag_filter, max_images

In [8]:
# 3a. DockerHub - Docker login credentials
docker_hub_id = created_ids[2]  # docker.io/andoniaf

secret_resp = pp(
    requests.post(
        f"{BASE_URL}/providers/secrets",
        headers=AUTH_HEADERS,
        json={
            "data": {
                "type": "provider-secrets",
                "attributes": {
                    "name": "DockerHub Login",
                    "secret_type": "static",
                    "secret": {
                        "registry_username": dockerhub_username,
                        "registry_password": dockerhub_password,
                    },
                },
                "relationships": {
                    "provider": {
                        "data": {"type": "providers", "id": docker_hub_id}
                    }
                },
            }
        },
    )
)

assert secret_resp.status_code == 201, "Secret creation failed"
secret_id = secret_resp.json()["data"]["id"]
print(f"\nSecret ID: {secret_id}")
HTTP 201
{
  "data": {
    "type": "provider-secrets",
    "id": "7f7aaecc-d5e3-49b2-ae5d-d230262bcf74",
    "attributes": {
      "inserted_at": "2026-02-17T17:22:33.354535Z",
      "updated_at": "2026-02-17T17:22:33.354683Z",
      "name": "DockerHub Login",
      "secret_type": "static"
    },
    "relationships": {
      "provider": {
        "data": {
          "type": "providers",
          "id": "d5ed4973-0b4e-4722-974f-bb05b99e95e7"
        }
      }
    }
  },
  "meta": {
    "version": "v1"
  }
}

Secret ID: 7f7aaecc-d5e3-49b2-ae5d-d230262bcf74
In [9]:
# 3b. ECR - Docker login credentials
ecr_id = created_ids[1]  # 714274078102.dkr.ecr.eu-west-1.amazonaws.com

token_secret_resp = pp(
    requests.post(
        f"{BASE_URL}/providers/secrets",
        headers=AUTH_HEADERS,
        json={
            "data": {
                "type": "provider-secrets",
                "attributes": {
                    "name": "ECR Docker Login",
                    "secret_type": "static",
                    "secret": {
                        "registry_username": ecr_username,
                        "registry_password": ecr_password,
                    },
                },
                "relationships": {
                    "provider": {
                        "data": {"type": "providers", "id": ecr_id}
                    }
                },
            }
        },
    )
)

assert token_secret_resp.status_code == 201
HTTP 201
{
  "data": {
    "type": "provider-secrets",
    "id": "bfb7c7b6-618a-427b-82e3-ff052974d636",
    "attributes": {
      "inserted_at": "2026-02-17T17:22:38.317768Z",
      "updated_at": "2026-02-17T17:22:38.317852Z",
      "name": "ECR Docker Login",
      "secret_type": "static"
    },
    "relationships": {
      "provider": {
        "data": {
          "type": "providers",
          "id": "7f93cb59-6e2a-471a-95af-8bfccfad57d6"
        }
      }
    }
  },
  "meta": {
    "version": "v1"
  }
}
In [ ]:
# 3c. GHCR - Empty secret (public registry, no auth)
filter_secret_resp = pp(
    requests.post(
        f"{BASE_URL}/providers/secrets",
        headers=AUTH_HEADERS,
        json={
            "data": {
                "type": "provider-secrets",
                "attributes": {
                    "name": "GHCR Public",
                    "secret_type": "static",
                    "secret": {},
                },
                "relationships": {
                    "provider": {
                        "data": {"type": "providers", "id": provider_id}
                    }
                },
            }
        },
    )
)

assert filter_secret_resp.status_code == 201

4. Test Provider Connection

The connection test verifies that the registry is accessible with the provided credentials. It uses the SDK's registry adapter to call list_repositories().

This returns a Task (HTTP 202) which we can poll.

In [10]:
conn_resp = pp(
    requests.post(
        f"{BASE_URL}/providers/{docker_hub_id}/connection",
        headers=AUTH_HEADERS,
    )
)

print(f"\nConnection test status: HTTP {conn_resp.status_code}")

if conn_resp.status_code == 202:
    task_id = conn_resp.json()["data"]["id"]
    print(f"Task ID: {task_id}")
    print("Polling task...")

    for _ in range(15):
        time.sleep(2)
        task_resp = requests.get(
            f"{BASE_URL}/tasks/{task_id}", headers=AUTH_HEADERS
        )
        state = task_resp.json()["data"]["attributes"]["state"]
        print(f"  Task state: {state}")
        if state.lower() not in ("executing", "available"):
            break

    # Check the provider's connection status
    provider_detail = requests.get(
        f"{BASE_URL}/providers/{docker_hub_id}", headers=AUTH_HEADERS
    )
    conn = provider_detail.json()["data"]["attributes"].get("connection", {})
    print(f"\nProvider connected: {conn.get('connected')}")
    print(f"Last checked: {conn.get('last_checked_at')}")
HTTP 202
{
  "data": {
    "type": "tasks",
    "id": "437becf4-82ca-4cb7-9215-57acb6f962be",
    "attributes": {
      "inserted_at": "2026-02-17T17:22:54.685371Z",
      "completed_at": "2026-02-17T17:22:54.672124Z",
      "name": "provider-connection-check",
      "state": "available",
      "result": null,
      "task_args": {
        "provider_id": "d5ed4973-0b4e-4722-974f-bb05b99e95e7"
      },
      "metadata": {}
    }
  },
  "meta": {
    "version": "v1"
  }
}

Connection test status: HTTP 202
Task ID: 437becf4-82ca-4cb7-9215-57acb6f962be
Polling task...
  Task state: failed

Provider connected: None
Last checked: None

5. Trigger a Scan

Create a new scan for the image provider. The API will dispatch a Celery task that runs the image provider's Trivy-based scanning logic.

In [11]:
scan_resp = pp(
    requests.post(
        f"{BASE_URL}/scans",
        headers=AUTH_HEADERS,
        json={
            "data": {
                "type": "scans",
                "attributes": {
                    "name": "Image Scan - DockerHub",
                },
                "relationships": {
                    "provider": {
                        "data": {"type": "providers", "id": docker_hub_id}
                    }
                },
            }
        },
    )
)

print(f"\nScan creation status: HTTP {scan_resp.status_code}")

if scan_resp.status_code in (201, 202):
    scan_data = scan_resp.json()["data"]
    scan_id = scan_data["id"]
    scan_type = scan_data["type"]
    print(f"Response type: {scan_type}")
    print(f"ID: {scan_id}")
    if scan_type == "tasks":
        print(f"Task state: {scan_data['attributes'].get('state')}")
    else:
        print(f"Scan state: {scan_data['attributes'].get('state')}")
HTTP 202
{
  "data": {
    "type": "tasks",
    "id": "340089d4-7207-4d4d-9ffb-c047d224abc3",
    "attributes": {
      "inserted_at": "2026-02-17T17:23:06.027956Z",
      "completed_at": "2026-02-17T17:23:05.998723Z",
      "name": "scan-perform",
      "state": "available",
      "result": null,
      "task_args": {
        "scan_id": "019c6ca0-8872-7d41-8bc2-edc64dca908f",
        "provider_id": "d5ed4973-0b4e-4722-974f-bb05b99e95e7"
      },
      "metadata": {}
    }
  },
  "meta": {
    "version": "v1"
  }
}

Scan creation status: HTTP 202
Response type: tasks
ID: 340089d4-7207-4d4d-9ffb-c047d224abc3
Task state: available

6. Monitor Scan Progress

Poll the scan endpoint until it completes or fails.

In [12]:
# If the scan creation returned a task, find the actual scan ID
if scan_type == "tasks":
    # The task is linked to the scan; get the scan from the scans list
    scans_resp = requests.get(
        f"{BASE_URL}/scans",
        headers=AUTH_HEADERS,
        params={"sort": "-inserted_at", "page[size]": 1},
    )
    scan_id = scans_resp.json()["data"][0]["id"]
    print(f"Found scan ID: {scan_id}")

print("Polling scan progress...")
for i in range(30):
    time.sleep(5)
    resp = requests.get(f"{BASE_URL}/scans/{scan_id}", headers=AUTH_HEADERS)
    data = resp.json()["data"]

    if data["type"] == "tasks":
        state = data["attributes"]["state"]
        print(f"  [{i*5:3d}s] Task state: {state}")
        if state.lower() != "executing":
            break
    else:
        attrs = data["attributes"]
        state = attrs["state"]
        progress = attrs.get("progress", 0)
        resources = attrs.get("unique_resource_count", 0)
        print(f"  [{i*5:3d}s] state={state} progress={progress}% resources={resources}")
        if state.lower() in ("completed", "failed", "cancelled"):
            break

# Final scan state
final = requests.get(f"{BASE_URL}/scans/{scan_id}", headers=AUTH_HEADERS)
print("\nFinal scan state:")
pp(final);
Found scan ID: 019c6ca0-8872-7d41-8bc2-edc64dca908f
Polling scan progress...
  [  0s] state=failed progress=0% resources=0

Final scan state:
HTTP 200
{
  "data": {
    "type": "scans",
    "id": "019c6ca0-8872-7d41-8bc2-edc64dca908f",
    "attributes": {
      "name": "Image Scan - DockerHub",
      "trigger": "manual",
      "state": "failed",
      "unique_resource_count": 0,
      "progress": 0,
      "duration": 0,
      "inserted_at": "2026-02-17T17:23:05.971882Z",
      "started_at": "2026-02-17T17:23:06.127765Z",
      "completed_at": "2026-02-17T17:23:06.279132Z",
      "scheduled_at": null,
      "next_scan_at": null
    },
    "relationships": {
      "provider": {
        "data": {
          "type": "providers",
          "id": "d5ed4973-0b4e-4722-974f-bb05b99e95e7"
        }
      },
      "task": {
        "data": {
          "type": "tasks",
          "id": "340089d4-7207-4d4d-9ffb-c047d224abc3"
        }
      },
      "processor": {
        "data": null
      }
    },
    "links": {
      "self": "http://localhost:8080/api/v1/scans/019c6ca0-8872-7d41-8bc2-edc64dca908f"
    }
  },
  "meta": {
    "version": "v1"
  }
}

7. Retrieve Findings

List findings from the scan, filtered by scan ID.

In [13]:
findings_resp = pp(
    requests.get(
        f"{BASE_URL}/findings",
        headers=AUTH_HEADERS,
        params={
            "filter[scan]": scan_id,
            "page[size]": 5,
            "sort": "-severity",
        },
    )
)

findings_data = findings_resp.json()
total = findings_data.get("meta", {}).get("pagination", {}).get("count", 0)
print(f"\nTotal findings: {total}")

for f in findings_data.get("data", []):
    attrs = f["attributes"]
    print(
        f"  [{attrs.get('severity', 'N/A'):8s}] "
        f"{attrs.get('status', 'N/A'):4s} - "
        f"{attrs.get('check_id', 'N/A')}"
    )
HTTP 200
{
  "links": {
    "first": "http://localhost:8080/api/v1/findings?filter%5Bscan%5D=019c6ca0-8872-7d41-8bc2-edc64dca908f&page%5Bnumber%5D=1&page%5Bsize%5D=5&sort=-severity",
    "last": "http://localhost:8080/api/v1/findings?filter%5Bscan%5D=019c6ca0-8872-7d41-8bc2-edc64dca908f&page%5Bnumber%5D=1&page%5Bsize%5D=5&sort=-severity",
    "next": null,
    "prev": null
  },
  "data": [],
  "meta": {
    "pagination": {
      "page": 1,
      "pages": 1,
      "count": 0
    },
    "version": "v1"
  }
}

Total findings: 0

7b. Retrieve Resources

List resources discovered during the scan.

In [14]:
from datetime import datetime, timedelta

# Resources endpoint requires a date filter
today = datetime.utcnow().strftime("%Y-%m-%d")

resources_resp = pp(
    requests.get(
        f"{BASE_URL}/resources",
        headers=AUTH_HEADERS,
        params={
            "filter[provider]": provider_id,
            "filter[updated_at.gte]": today,
            "page[size]": 10,
        },
    )
)

resources_data = resources_resp.json()
total_resources = resources_data.get("meta", {}).get("pagination", {}).get("count", 0)
print(f"\nTotal resources: {total_resources}")

for r in resources_data.get("data", []):
    attrs = r["attributes"]
    print(f"  {attrs.get('uid', 'N/A'):60s} type={attrs.get('type', 'N/A')}")
HTTP 200
{
  "links": {
    "first": "http://localhost:8080/api/v1/resources?filter%5Bprovider%5D=1a00fe5f-f137-4a63-81f1-8ac7ce0a57ed&filter%5Bupdated_at.gte%5D=2026-02-17&page%5Bnumber%5D=1&page%5Bsize%5D=10",
    "last": "http://localhost:8080/api/v1/resources?filter%5Bprovider%5D=1a00fe5f-f137-4a63-81f1-8ac7ce0a57ed&filter%5Bupdated_at.gte%5D=2026-02-17&page%5Bnumber%5D=1&page%5Bsize%5D=10",
    "next": null,
    "prev": null
  },
  "data": [],
  "meta": {
    "pagination": {
      "page": 1,
      "pages": 1,
      "count": 0
    },
    "version": "v1"
  }
}

Total resources: 0
/var/folders/3z/2_mtblb94xx5br2qrg1c2y440000gn/T/ipykernel_80028/708590820.py:4: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
  today = datetime.utcnow().strftime("%Y-%m-%d")

8. Cleanup

Delete the test providers created during this notebook.

In [13]:
print(f"Cleaning up {len(created_ids)} provider(s)...")
for pid in created_ids:
    resp = requests.delete(f"{BASE_URL}/providers/{pid}", headers=AUTH_HEADERS)
    print(f"  DELETE {pid} -> HTTP {resp.status_code}")

# Verify cleanup
check = requests.get(
    f"{BASE_URL}/providers",
    headers=AUTH_HEADERS,
    params={"filter[provider]": "image"},
)
remaining = check.json()["meta"]["pagination"]["count"]
print(f"\nRemaining image providers: {remaining}")
Cleaning up 2 provider(s)...
  DELETE 4b62c499-380c-494f-be54-251088a2e404 -> HTTP 404
  DELETE da5038fe-b3ff-4d1b-9be1-15fc4df50f00 -> HTTP 404

Remaining image providers: 0

Summary

Endpoint Method Purpose Status
/providers POST Create image provider Tested
/providers GET List providers (filter by image) Tested
/providers/{id} GET Get provider details Tested
/providers/{id} DELETE Soft-delete provider Tested
/providers/{id}/connection POST Test registry connection Tested
/providers/secrets POST Create secret (docker login, token, empty, filters) Tested
/scans POST Trigger image scan Tested
/scans/{id} GET Monitor scan progress Tested
/findings GET Retrieve scan findings Tested
/resources GET Retrieve scan resources Tested