diff --git a/extensions/vulnerabilities-by-content/app.py b/extensions/vulnerabilities-by-content/app.py
new file mode 100644
index 00000000..d3af9593
--- /dev/null
+++ b/extensions/vulnerabilities-by-content/app.py
@@ -0,0 +1,188 @@
+import re
+from datetime import datetime
+from functools import cache
+from typing import Callable
+import requests
+import json
+
+import pandas as pd
+from htmltools import tags
+from packaging.specifiers import InvalidSpecifier, SpecifierSet
+from posit.connect import Client
+from shiny import reactive
+from shiny.express import input, render, ui
+
+client = Client()
+packages = reactive.value(pd.DataFrame([]))
+
+with ui.sidebar():
+    ui.input_text("guid", "Content GUID")
+
+@render.text
+def datagrid_label():
+    ui.busy_indicators.options(spinner_type=None)
+    if num_packages() > 0:
+        return f"{num_packages()} packages for content '{content_title()}'"
+    else:
+        return "Please enter a content GUID to search for packages."
+
+def update_packages(app_packages):
+    data = prepare_packages_data(get_packages(app_packages))
+    packages.set(data)
+
+
+@render.data_frame
+def app_grid():
+    app_packages = packages_spec()
+    if not app_packages:
+        return None
+    update_packages(app_packages)
+    return render.DataGrid(
+        packages.get(),
+        width="100%",
+        height="100%",
+        selection_mode="rows",
+        styles=dict(style={"white-space": "nowrap"}),
+    )
+
+
+@reactive.calc
+def num_packages():
+    return len(packages.get())
+
+@reactive.calc
+def content_title():
+    if input.guid() == "":
+        return
+    response = client.get(
+        f"v1/content/{input.guid()}",
+    )
+    if response.status_code != 200:
+        raise Exception(f"Failed to search for {input.guid()}: {response.text}")
+
+    data = response.json()
+    title = data.get("title")
+    if title is None:
+        raise Exception(f"Invalid search response from server: {response.text}")
+
+    return title
+
+
+@reactive.calc
+def packages_spec():
+    if input.guid() == "":
+        return
+    return get_app_packages(input.guid())
+
+def get_packages(app_packages) -> pd.DataFrame:
+    package_strs = []
+    for package in app_packages:
+        package_str = f'{package["name"]}=={package["version"]}'
+        package_strs.append(package_str)
+    
+    response = stream_packages_info(package_strs)
+    for line in response.iter_lines():
+        if line:
+            try:
+                package_data = json.loads(line)
+                for package in app_packages:
+                    if package_data.get('name') == package.get('name') and package_data.get('version') == package.get('version'):
+                        vulns = package_data.get('vulns', [])
+                        cves = []
+                        for vuln in vulns:
+                            cves.append(vuln["id"])
+                        available_versions = package_data.get('available_versions', [])
+                        package["cves"] = cves
+                        package["available_versions"] = ", ".join(available_versions)
+                        package["package"] = f"{package.get('name')} {package.get('version')}"
+
+                        # The newest version is always the first
+                        # TODO we aren't properly accounting for dev versions and such, need to correctly parse versions
+                        package["up_to_date"] = get_up_to_date(package.get('version'), available_versions[0])
+                        break
+
+            except json.JSONDecodeError as e:
+                print(f"Error parsing JSON: {e}")
+
+    print(f"Found {len(app_packages)} packages for content '{content_title()}'")
+    return pd.DataFrame(app_packages)
+
+# TODO don't hard code this
+def stream_packages_info(names):
+    url = "http://ec2-3-84-118-18.compute-1.amazonaws.com/__api__/filter/packages"
+    headers = {
+        "Content-Type": "application/json",
+        "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwYWNrYWdlbWFuYWdlciIsImp0aSI6IjJmMDQ5MGNmLWE5NTgtNDQzNy1hZGRmLWJhNzQ0MWNhM2NiNiIsImlhdCI6MTczNjQzOTgzMiwiaXNzIjoicGFja2FnZW1hbmFnZXIiLCJzY29wZXMiOnsiZ2xvYmFsIjoiYWRtaW4ifX0.Ptu-FXvBjw5vJQE7xqhAJqcy2i5_-CLQsH1HITpjpYQ"
+    }
+
+    data = {
+        "names": names,
+        "repo": "pypi",
+        "snapshot": "latest"
+    }
+
+    return requests.post(url, json=data, headers=headers, stream=True)
+
+def get_app_packages(app_guid: str):
+    # get the specific version used
+    package_response = client.get(
+        f'v1/content/{app_guid}/packages'
+    )
+
+    if package_response.status_code != 200:
+        raise Exception(f'Failed to get packages for {app_guid}: {package_response.text}')
+
+    package_data = package_response.json()
+    if package_data is None:
+        raise Exception(f"Invalid packages response from server: {package_response.text}")
+
+    return package_data
+    
+
+def get_up_to_date(current_version, latest_version):
+    if current_version >= latest_version:
+        return "โœ…"
+    return "โŒ"
+
+def prepare_packages_data(df: pd.DataFrame) -> pd.DataFrame:
+    if len(df) == 0:
+        return df
+    
+    for i, row in df.iterrows():
+
+        df.at[i, "package_link"] = tags.a(
+            tags.span("โ†—", style="font-size: 1.5em"),
+            target="_blank",
+            href=f"http://ec2-3-84-118-18.compute-1.amazonaws.com/client/#/repos/pypi/packages/overview?search={row['name']}",
+        )
+
+        if row.cves:
+            vulns = []
+            for cve in row.cves:
+                link = tags.a(
+                    tags.span(cve, style="font-size: 1em"),
+                    target="_blank",
+                    href=f"https://osv.dev/vulnerability/{cve}"
+                )
+                vulns.append(link)
+
+            df.at[i, "cves"] = tags.span([
+                item if i == 0 else [", ", item] 
+                for i, item in enumerate(vulns)
+            ])
+
+    columns = {
+        "package_link": "Link",
+        "name": "Name",
+        "version": "Version",
+        "cves": "Known Vulnerabilities",
+        "up_to_date": "Up to Date",
+        "available_versions": "Available Versions at Snapshot",
+    }
+    df = df[columns.keys()].rename(columns=columns)
+    return df
+
+
+@reactive.calc
+def has_selection():
+    return len(input.app_grid_selected_rows()) > 0
diff --git a/extensions/vulnerabilities-by-content/connect-extension.toml b/extensions/vulnerabilities-by-content/connect-extension.toml
new file mode 100644
index 00000000..f6eb3eab
--- /dev/null
+++ b/extensions/vulnerabilities-by-content/connect-extension.toml
@@ -0,0 +1,4 @@
+name = "connect-extension-vulnerabilities-by-content"
+title = "Find Content Vulnerabilities by Content"
+description = "Connect Extension: Find Content Vulnerabilities by Content"
+access_type = "logged_in"
diff --git a/extensions/vulnerabilities-by-content/manifest.json b/extensions/vulnerabilities-by-content/manifest.json
new file mode 100644
index 00000000..0575d9da
--- /dev/null
+++ b/extensions/vulnerabilities-by-content/manifest.json
@@ -0,0 +1,24 @@
+{
+    "version": 1,
+    "locale": "en_US.UTF-8",
+    "metadata": {
+      "appmode": "python-shiny",
+      "entrypoint": "shiny.express.app:app_2e_py"
+    },
+    "python": {
+      "version": "3.11.9",
+      "package_manager": {
+        "name": "pip",
+        "version": "24.2",
+        "package_file": "requirements.txt"
+      }
+    },
+    "files": {
+      "requirements.txt": {
+        "checksum": "2ed393d51266e315d6e7b55ac26c1062"
+      },
+      "app.py": {
+        "checksum": "61ddac9f526f0d55ab94e3b02eae4070"
+      }
+    }
+  }
diff --git a/extensions/vulnerabilities-by-content/requirements.txt b/extensions/vulnerabilities-by-content/requirements.txt
new file mode 100644
index 00000000..159a0d6f
--- /dev/null
+++ b/extensions/vulnerabilities-by-content/requirements.txt
@@ -0,0 +1,7 @@
+htmltools
+packaging
+pandas
+posit-sdk
+shiny
+mobsf==4.1.3
+requests
\ No newline at end of file
diff --git a/extensions/vulnerabilities-by-package/app.py b/extensions/vulnerabilities-by-package/app.py
new file mode 100644
index 00000000..016289cd
--- /dev/null
+++ b/extensions/vulnerabilities-by-package/app.py
@@ -0,0 +1,417 @@
+import re
+from datetime import datetime
+from functools import cache
+from typing import Callable
+import requests
+import json
+from collections import defaultdict
+
+
+import pandas as pd
+from htmltools import tags
+from packaging.specifiers import InvalidSpecifier, SpecifierSet
+from posit.connect import Client
+from posit.connect.errors import ClientError
+from shiny import reactive
+from shiny.express import input, render, ui
+
+
+# This is the max page size accepted by the API.
+# Use this until we have pagination.
+page_size = 500
+
+client = Client()
+apps = reactive.value(pd.DataFrame([]))
+
+def fetch_all_packages():
+    page_number = 1
+    all_packages = []
+
+    while True:
+        response = client.get(
+            "v1/packages",
+            params={
+                "name": input.name(),
+                "page_size": page_size,
+                "page_number": page_number
+            },
+        )
+        if response.status_code != 200:
+            raise Exception(f"Failed to get all packages")
+
+        data = response.json()
+        results = data.get("results")
+        if results is None:
+            raise Exception(f"Invalid packages response from server: {response.text}")
+
+        if not results:
+            # paged all the way through
+            break
+
+        all_packages.extend(results)
+        page_number += 1
+    return all_packages
+
+with ui.sidebar():
+    ui.input_text("name", "Package Name")
+    ui.input_text("min_version", "Minimum version (>=)")
+    ui.input_text("max_version", "Maximum version (<)")
+    ui.hr()
+    ui.input_action_button("lock_selected", "Lock Selected")
+    ui.input_action_button("delete_selected", "Delete Selected")
+
+
+@render.text
+def datagrid_label():
+    ui.busy_indicators.options(spinner_type=None)
+    if has_valid_spec():
+        return f"{num_apps()} items using {package_spec()}"
+    else:
+        return "Please enter a package name and optional versions."
+
+
+def update_apps():
+    spec = package_spec()
+    if not has_valid_spec():
+        return
+    data = prepare_app_data(get_apps(spec))
+    apps.set(data)
+
+
+@render.data_frame
+def app_grid():
+    if not has_valid_spec():
+        return None
+    update_apps()
+    return render.DataGrid(
+        apps.get(),
+        width="100%",
+        height="100%",
+        selection_mode="rows",
+        styles=dict(style={"white-space": "nowrap"}),
+    )
+
+
+@reactive.calc
+def num_apps():
+    return len(apps.get())
+
+@reactive.calc
+def package_spec():
+    spec = input.name()
+    if spec == "":
+        return ""
+    if input.min_version() != "":
+        spec += ">=" + input.min_version()
+    if input.max_version() != "":
+        comma = "," if input.min_version() != "" else ""
+        spec += comma + "<" + input.max_version()
+    return f'package:"{spec}"'
+
+@reactive.calc
+def package_range():
+    spec = input.name()
+    if spec == "":
+        return ""
+    elif input.min_version() == "" and input.max_version() == "":
+        return f"{input.name()}>0.0,<=99.99"
+    if input.min_version() != "":
+        spec += ">=" + input.min_version()
+    if input.max_version() != "":
+        comma = "," if input.min_version() != "" else ""
+        spec += comma + "<" + input.max_version()
+    return spec
+
+
+@reactive.calc
+def has_valid_spec():
+    spec = package_spec()
+    if not spec:
+        return False
+
+    match = re.match(r'package:"[A-Za-z0-9_.-]+(.*)"', spec)
+    if not match:
+        return False
+
+    versionSpec = match.group(1)
+    if not versionSpec:
+        return True
+
+    try:
+        _ = SpecifierSet(versionSpec)
+        return True
+    except InvalidSpecifier:
+        return False
+
+
+def get_apps(spec: str) -> pd.DataFrame:
+    page_number = 1
+    all_apps = []
+
+    all_packages = fetch_all_packages()
+    vulns_map = fetch_all_ppm_packages()
+
+    for package in all_packages:
+        # TODO hacky for the demo, only use python versions
+        if package["language"] != "python":
+            continue 
+
+        # TODO version parsing doesn't filter at all currently
+
+        response = client.get(
+            "v1/content/"+package["app_guid"],
+             params={
+                "include": "owner"
+            },
+        )
+        if response.status_code != 200:
+            raise Exception(f"Failed to search for {spec}: {response.text}")
+
+        app = response.json()
+        if app is None:
+            raise Exception(f"Invalid search response from server: {response.text}")
+
+        if not app:
+            # paged all the way through
+            break
+
+        # flatten the included owner sub-object
+        owner = app["owner"]
+        app["owner_username"] = owner["username"]
+        app["owner_first_name"] = owner["first_name"]
+        app["owner_last_name"] = owner["last_name"]
+
+        app["cves"] = vulns_map[package["version"]]
+        app["package_name"] = package["name"]
+        app["package"] = f'{package["name"]} {package["version"]}'
+
+        all_apps.append(app)
+        page_number += 1
+
+    print(f"Found {len(all_apps)} apps matching '{spec}'")
+    return pd.DataFrame(all_apps)
+
+def get_package_info(app_packages, package_name: str):
+    for package in app_packages:
+        if package["name"] == package_name:
+            return package
+
+def fetch_all_ppm_packages() -> list:
+
+    package_str = package_range()
+    names = []
+    names.append(package_str)
+
+    vulns_map = defaultdict(list)
+
+    response = stream_packages_info(names)
+    for line in response.iter_lines():
+        if line:
+            try:
+                package_data = json.loads(line)
+                vulns = package_data.get('vulns', [])
+                cves = []
+                for vuln in vulns:
+                    cves.append(vuln["id"])
+                vulns_map[package_data["version"]] = cves
+
+            except json.JSONDecodeError as e:
+                print(f"Error parsing JSON: {e}")
+    return vulns_map
+
+# TODO don't hard code this
+def stream_packages_info(names):
+    url = "http://ec2-3-84-118-18.compute-1.amazonaws.com/__api__/filter/packages"
+    headers = {
+        "Content-Type": "application/json",
+        "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwYWNrYWdlbWFuYWdlciIsImp0aSI6IjJmMDQ5MGNmLWE5NTgtNDQzNy1hZGRmLWJhNzQ0MWNhM2NiNiIsImlhdCI6MTczNjQzOTgzMiwiaXNzIjoicGFja2FnZW1hbmFnZXIiLCJzY29wZXMiOnsiZ2xvYmFsIjoiYWRtaW4ifX0.Ptu-FXvBjw5vJQE7xqhAJqcy2i5_-CLQsH1HITpjpYQ"
+    }
+
+    data = {
+        "names": names,
+        "repo": "pypi", # TODO add logic for R vs Python
+        "snapshot": "latest"
+    }
+
+    return requests.post(url, json=data, headers=headers, stream=True)
+    
+
+def get_display_name(row):
+    if row.owner_first_name and row.owner_last_name:
+        name = row.owner_first_name + " " + row.owner_last_name
+    elif row.owner_first_name:
+        name = row.owner_first_name
+    elif row.owner_last_name:
+        name = row.owner_last_name
+    else:
+        return row.owner_username
+    return f"{row.owner_username} ({name})"
+
+
+@cache
+def get_owner_email(owner_guid: str) -> str:
+    user = client.users.get(owner_guid)
+    return user.email
+
+
+def prepare_app_data(df: pd.DataFrame) -> pd.DataFrame:
+    if len(df) == 0:
+        return df
+
+    df["title_link"] = None
+    df["owner"] = None
+    df["owner_email"] = None
+    df["email_link"] = None
+    df["created"] = pd.to_datetime(df["created_time"]).dt.date
+    for i, row in df.iterrows():
+        df.at[i, "app_link"] = tags.a(
+            tags.span("โ†—", style="font-size: 1.5em"),
+            target="_blank",
+            href=row.dashboard_url,
+        )
+        df.at[i, "package"] = tags.a(
+            tags.span(row.package, style="font-size: 1em"),
+            target="_blank",
+            href=f"http://ec2-3-84-118-18.compute-1.amazonaws.com/client/#/repos/pypi/packages/overview?search={row.package_name}",
+        )
+        if row.cves:
+            vulns = []
+
+            for cve in row.cves:
+                link = tags.a(
+                    tags.span(cve, style="font-size: 1em"),
+                    target="_blank",
+                    href=f"https://osv.dev/vulnerability/{cve}"
+                )
+                vulns.append(link)
+
+            if vulns:
+                df.at[i, "cves"] = tags.span([
+                    item if i == 0 else [", ", item] 
+                    for i, item in enumerate(vulns)
+                ])
+            else:
+                df.at[i, "cves"] = ""
+        else:
+            df.at[i, "cves"] = ""
+
+        title_len = 50
+        title = df.at[i, "title"] or ""
+        df.at[i, "title"] = title[:title_len] + (
+            "..." if len(title) > title_len else ""
+        )
+
+        display_name = get_display_name(row)
+        email = get_owner_email(row.owner_guid)
+
+        df.at[i, "owner_display_name"] = display_name
+        df.at[i, "owner_email"] = email
+        if email:
+            df.at[i, "email_link"] = tags.a(email, href=f"mailto:{email}")
+        else:
+            df.at[i, "email_link"] = email
+
+        df.at[i, "lock_icon"] = "๐Ÿ”’" if row.locked else ""
+
+        if row.bundle_id:
+            bundle_url = f"{client.cfg.url}/v1/content/{row.guid}/bundles/{row.bundle_id}/download"
+            df.at[i, "bundle_link"] = tags.a(
+                tags.span("โค“", style="font-size: 1.5em"),
+                target="_blank",
+                href=bundle_url,
+            )
+
+    columns = {
+        "app_link": "App",
+        "id": "ID",
+        "title": "Title",
+        "package": "Package",
+        "cves": "Known Vulnerabilities",
+        "owner_display_name": "Owner",
+        "email_link": "Email",
+        "created": "Created",
+        "guid": "GUID",
+        "bundle_link": "Download",
+        "lock_icon": "Locked",
+    }
+    df = df[columns.keys()].rename(columns=columns)
+    return df
+
+
+@reactive.calc
+def has_selection():
+    return len(input.app_grid_selected_rows()) > 0
+
+
+def each_selected_app(message: str, func: Callable[[str], bool]):
+    rows = input.app_grid_selected_rows()
+    if not rows:
+        return
+
+    selected_guids = apps.get().loc[list(rows)]["GUID"].tolist()
+    print("Locking selected apps:", selected_guids)
+
+    with ui.Progress(min=0, max=len(selected_guids)) as p:
+        p.set(message=message + "...", value=0)
+        success_count = 0
+
+        for i, guid in enumerate(selected_guids):
+            if func(guid):
+                success_count += 1
+            p.set(value=i + 1)
+
+    update_apps()
+    return success_count
+
+
+
+@reactive.effect
+@reactive.event(input.lock_selected)
+def lock_selected_apps():
+    rows = input.app_grid_selected_rows()
+    if not rows:
+        return
+
+    successes = each_selected_app("Locking applications", lock_app)
+    ui.notification_show(
+        f"Locked {successes} out of {len(rows)} applications",
+        type="message",
+    )
+
+
+def lock_app(guid):
+    try:
+        content = client.content.get(guid)
+        today = datetime.now().date().isoformat()
+        content.update(
+            locked=True,
+            locked_message=f"Locked on {today} because it contains a vulnerable version of '{input.name()}'",
+        )
+        return True
+    except Exception as e:
+        print(f"Error locking app {guid}: {e}")
+        return False
+
+
+@reactive.effect
+@reactive.event(input.delete_selected)
+def delete_selected_apps():
+    rows = input.app_grid_selected_rows()
+    if not rows:
+        return
+
+    successes = each_selected_app("Deleting applications", delete_app)
+    ui.notification_show(
+        f"Deleted {successes} out of {len(rows)} applications",
+        type="message",
+    )
+
+
+def delete_app(guid):
+    try:
+        content = client.content.get(guid)
+        content.delete()
+        return True
+    except Exception as e:
+        print(f"Error deleting app {guid}: {e}")
+        return False
diff --git a/extensions/vulnerabilities-by-package/connect-extension.toml b/extensions/vulnerabilities-by-package/connect-extension.toml
new file mode 100644
index 00000000..e5da7b9d
--- /dev/null
+++ b/extensions/vulnerabilities-by-package/connect-extension.toml
@@ -0,0 +1,4 @@
+name = "connect-extension-vulnerabilities-by-package"
+title = "Find Content Vulnerabilities by Package"
+description = "Connect Extension: Find Content Vulnerabilities by Package"
+access_type = "logged_in"
diff --git a/extensions/vulnerabilities-by-package/manifest.json b/extensions/vulnerabilities-by-package/manifest.json
new file mode 100644
index 00000000..0575d9da
--- /dev/null
+++ b/extensions/vulnerabilities-by-package/manifest.json
@@ -0,0 +1,24 @@
+{
+    "version": 1,
+    "locale": "en_US.UTF-8",
+    "metadata": {
+      "appmode": "python-shiny",
+      "entrypoint": "shiny.express.app:app_2e_py"
+    },
+    "python": {
+      "version": "3.11.9",
+      "package_manager": {
+        "name": "pip",
+        "version": "24.2",
+        "package_file": "requirements.txt"
+      }
+    },
+    "files": {
+      "requirements.txt": {
+        "checksum": "2ed393d51266e315d6e7b55ac26c1062"
+      },
+      "app.py": {
+        "checksum": "61ddac9f526f0d55ab94e3b02eae4070"
+      }
+    }
+  }
diff --git a/extensions/vulnerabilities-by-package/requirements.txt b/extensions/vulnerabilities-by-package/requirements.txt
new file mode 100644
index 00000000..159a0d6f
--- /dev/null
+++ b/extensions/vulnerabilities-by-package/requirements.txt
@@ -0,0 +1,7 @@
+htmltools
+packaging
+pandas
+posit-sdk
+shiny
+mobsf==4.1.3
+requests
\ No newline at end of file