diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 26898ec789..69c0abf597 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -40,6 +40,11 @@ updates:
schedule:
interval: daily
+ - package-ecosystem: docker
+ directory: /docker/semgrep
+ schedule:
+ interval: daily
+
- package-ecosystem: github-actions
directory: /
schedule:
diff --git a/.github/workflows/run-ci-cd.yaml b/.github/workflows/run-ci-cd.yaml
index 6df6a9ce23..49e0485d51 100644
--- a/.github/workflows/run-ci-cd.yaml
+++ b/.github/workflows/run-ci-cd.yaml
@@ -400,6 +400,30 @@ jobs:
docker run --env-file frontend/.env.example owasp/nest:test-frontend-a11y-latest pnpm run test:a11y
timeout-minutes: 5
+ run-security-scan:
+ name: Run security scan
+ needs:
+ - scan-code
+ - scan-ci-dependencies
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+
+ - name: Run Security CI Scan
+ run: make security-scan
+
+ - name: Upload security scan report
+ if: always()
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
+ with:
+ name: semgrep-results-run-${{ github.run_number }}
+ path: semgrep-security-report.txt
+ retention-days: 14
+ timeout-minutes: 15
+
set-release-version:
name: Set release version
outputs:
@@ -450,6 +474,7 @@ jobs:
- run-frontend-e2e-tests
- run-frontend-unit-tests
- set-release-version
+ - run-security-scan
permissions:
contents: read
runs-on: ubuntu-latest
@@ -820,6 +845,7 @@ jobs:
- run-frontend-e2e-tests
- run-frontend-unit-tests
- set-release-version
+ - run-security-scan
permissions:
contents: read
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 6780666977..f15b7fa412 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,8 @@ design/
frontend/blob-report/
frontend/coverage
frontend/dist
+frontend/.next
+frontend/out
frontend/npm-debug.log*
frontend/playwright-report/
frontend/playwright/.cache/
@@ -50,3 +52,4 @@ venv/
# Snyk Security Extension - AI Rules (auto-generated)
.cursor/rules/snyk_rules.mdc
+semgrep-security-report.txt
diff --git a/Makefile b/Makefile
index a409222c78..4e18e7f758 100644
--- a/Makefile
+++ b/Makefile
@@ -58,6 +58,30 @@ scan-images: \
scan-backend-image \
scan-frontend-image
+security-scan:
+ @echo "Running Security Scan..."
+ @docker run --rm \
+ -v "$(PWD):/src" \
+ -w /src \
+ $$(grep -E '^FROM semgrep/semgrep:' docker/semgrep/Dockerfile | sed 's/^FROM //') \
+ semgrep \
+ --config p/ci \
+ --config p/javascript \
+ --config p/nginx \
+ --config p/owasp-top-ten \
+ --config p/python \
+ --config p/secrets \
+ --config p/security-audit \
+ --config p/sql-injection \
+ --config p/typescript \
+ --error \
+ --skip-unknown-extensions \
+ --timeout 10 \
+ --timeout-threshold 3 \
+ --text \
+ --text-output=semgrep-security-report.txt \
+ .
+
test: \
test-nest-app
@@ -78,3 +102,5 @@ update-nest-app-dependencies: \
update-pre-commit:
@pre-commit autoupdate
+
+.PHONY: build clean check pre-commit prune run scan-images security-scan test update
diff --git a/backend/apps/api/decorators/cache.py b/backend/apps/api/decorators/cache.py
index 655710cfae..3da046d8c6 100644
--- a/backend/apps/api/decorators/cache.py
+++ b/backend/apps/api/decorators/cache.py
@@ -13,7 +13,8 @@ def generate_key(
prefix: str,
):
"""Generate a cache key for a request."""
- return f"{prefix}:{request.get_full_path()}"
+ # Reason: This generates an internal cache key, not a user-facing HTML response.
+ return f"{prefix}:{request.get_full_path()}" # nosemgrep: python.flask.security.audit.directly-returned-format-string.directly-returned-format-string # noqa: E501
def cache_response(
diff --git a/backend/apps/api/internal/mutations/api_key.py b/backend/apps/api/internal/mutations/api_key.py
index edbcc49a13..1d557541a7 100644
--- a/backend/apps/api/internal/mutations/api_key.py
+++ b/backend/apps/api/internal/mutations/api_key.py
@@ -77,7 +77,10 @@ def create_api_key(self, info: Info, name: str, expires_at: datetime) -> CreateA
message="API key created successfully.",
)
except IntegrityError as err:
- logger.warning("Error creating API key: %s", err)
+ # Reason: Logging the error message string only, not the sensitive API key credential itself. # noqa: E501
+ logger.warning( # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure # noqa: E501
+ "Error creating API key: %s", err
+ )
return CreateApiKeyResult(
ok=False,
code="ERROR",
diff --git a/backend/apps/github/admin/issue.py b/backend/apps/github/admin/issue.py
index 3c397dcd85..17f87e64c8 100644
--- a/backend/apps/github/admin/issue.py
+++ b/backend/apps/github/admin/issue.py
@@ -1,7 +1,7 @@
"""GitHub app Issue model admin."""
from django.contrib import admin
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html
from apps.github.models.issue import Issue
@@ -38,7 +38,7 @@ def custom_field_github_url(self, obj) -> str:
str: A safe HTML link to the issue on GitHub.
"""
- return mark_safe(f"↗️") # noqa: S308
+ return format_html("↗️", obj.url)
custom_field_github_url.short_description = "GitHub 🔗"
diff --git a/backend/apps/github/admin/pull_request.py b/backend/apps/github/admin/pull_request.py
index 02937114b4..f5c5d00f3d 100644
--- a/backend/apps/github/admin/pull_request.py
+++ b/backend/apps/github/admin/pull_request.py
@@ -1,7 +1,7 @@
"""GitHub app PullRequest model admin."""
from django.contrib import admin
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html
from apps.github.models.pull_request import PullRequest
@@ -44,7 +44,7 @@ def custom_field_github_url(self, obj: PullRequest) -> str:
str: A safe HTML link to the pull request on GitHub.
"""
- return mark_safe(f"↗️") # noqa: S308
+ return format_html("↗️", obj.url)
custom_field_github_url.short_description = "GitHub 🔗"
diff --git a/backend/apps/github/admin/repository.py b/backend/apps/github/admin/repository.py
index 66dcdc18aa..ba17eb19ca 100644
--- a/backend/apps/github/admin/repository.py
+++ b/backend/apps/github/admin/repository.py
@@ -1,7 +1,7 @@
"""GitHub app Repository model admin."""
from django.contrib import admin
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html
from apps.github.models.repository import Repository
@@ -46,8 +46,10 @@ def custom_field_github_url(self, obj) -> str:
str: A safe HTML link to the repository on GitHub.
"""
- return mark_safe( # noqa: S308
- f"↗️"
+ return format_html(
+ "↗️",
+ obj.owner.login,
+ obj.name,
)
def custom_field_title(self, obj: Repository) -> str:
diff --git a/backend/apps/owasp/admin/mixins.py b/backend/apps/owasp/admin/mixins.py
index 9addfc4a6e..59b7839484 100644
--- a/backend/apps/owasp/admin/mixins.py
+++ b/backend/apps/owasp/admin/mixins.py
@@ -2,7 +2,7 @@
from django.contrib.contenttypes.admin import GenericTabularInline
from django.contrib.contenttypes.models import ContentType
-from django.utils.html import escape
+from django.utils.html import format_html
from django.utils.safestring import mark_safe
from apps.owasp.admin.widgets import ChannelIdWidget
@@ -106,10 +106,10 @@ def custom_field_github_urls(self, obj):
return ""
return self._format_github_link(obj.owasp_repository)
- return mark_safe( # noqa: S308
- " ".join(
- [self._format_github_link(repository) for repository in obj.repositories.all()]
- )
+ links = [self._format_github_link(repository) for repository in obj.repositories.all()]
+ # Use mark_safe since links are already safely formatted HTML from format_html
+ return mark_safe( # noqa: S308 # NOSEMGREP: python.django.security.audit.avoid-mark-safe.avoid-mark-safe
+ " ".join(links)
)
def custom_field_owasp_url(self, obj):
@@ -117,9 +117,7 @@ def custom_field_owasp_url(self, obj):
if not hasattr(obj, "key") or not obj.key:
return ""
- return mark_safe( # noqa: S308
- f"↗️"
- )
+ return format_html("↗️", obj.key)
def _format_github_link(self, repository):
"""Format a single GitHub repository link."""
@@ -130,9 +128,10 @@ def _format_github_link(self, repository):
if not hasattr(repository, "key") or not repository.key:
return ""
- return mark_safe( # noqa: S308
- f"↗️"
+ return format_html(
+ "↗️",
+ repository.owner.login,
+ repository.key,
)
custom_field_github_urls.short_description = "GitHub 🔗"
diff --git a/backend/apps/owasp/admin/widgets.py b/backend/apps/owasp/admin/widgets.py
index 7765ade609..409fdee692 100644
--- a/backend/apps/owasp/admin/widgets.py
+++ b/backend/apps/owasp/admin/widgets.py
@@ -21,4 +21,7 @@ def render(self, name, value, attrs=None, renderer=None):
f"id='lookup_id_{name}' title='Look up related objects'>"
)
- return mark_safe(f"{widget_html}{search_button}") # noqa: S308
+ # Use mark_safe since both widget_html and search_button are already HTML
+ return mark_safe( # noqa: S308 # NOSEMGREP: python.django.security.audit.avoid-mark-safe.avoid-mark-safe
+ f"{widget_html} {search_button}"
+ )
diff --git a/backend/apps/slack/views.py b/backend/apps/slack/views.py
index a3983bf11c..229479e23b 100644
--- a/backend/apps/slack/views.py
+++ b/backend/apps/slack/views.py
@@ -9,7 +9,7 @@
slack_handler = SlackRequestHandler(SlackConfig.app)
-@csrf_exempt # NOSONAR
+@csrf_exempt # NOSEMGREP: python.django.security.audit.csrf-exempt.no-csrf-exempt # NOSONAR
@require_POST # NOSONAR
def slack_request_handler(request):
"""Handle Slack requests.
@@ -20,5 +20,10 @@ def slack_request_handler(request):
Returns:
HttpResponse: The response generated by the SlackRequestHandler.
+ Note:
+ CSRF exemption is required for webhook endpoints. Security is ensured through
+ Slack's request signature verification, which is automatically handled by
+ slack-bolt's SlackRequestHandler when signing_secret is configured in SlackConfig.app.
+
"""
return slack_handler.handle(request)
diff --git a/cspell/Dockerfile b/cspell/Dockerfile
index 1e0069ca51..ed5501599a 100644
--- a/cspell/Dockerfile
+++ b/cspell/Dockerfile
@@ -17,7 +17,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /nest
-
+# Reason: This is a dev-tool linter, not a production service. Root access is required for IO operations.
+# nosemgrep: dockerfile.security.missing-user-entrypoint.missing-user-entrypoint
ENTRYPOINT ["/opt/node/node_modules/.bin/cspell"]
USER node
diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt
index f99e174826..19a37e374e 100644
--- a/cspell/custom-dict.txt
+++ b/cspell/custom-dict.txt
@@ -117,6 +117,7 @@ navlink
nestbot
ngx
noinput
+nosemgrep
nosniff
nspname
numfmt
@@ -147,6 +148,7 @@ saft
sakanashi
samm
schemathesis
+semgrep
seo
skillstruck
slackbot
diff --git a/docker/cspell/Dockerfile b/docker/cspell/Dockerfile
index 1e0069ca51..ed5501599a 100644
--- a/docker/cspell/Dockerfile
+++ b/docker/cspell/Dockerfile
@@ -17,7 +17,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /nest
-
+# Reason: This is a dev-tool linter, not a production service. Root access is required for IO operations.
+# nosemgrep: dockerfile.security.missing-user-entrypoint.missing-user-entrypoint
ENTRYPOINT ["/opt/node/node_modules/.bin/cspell"]
USER node
diff --git a/docker/semgrep/Dockerfile b/docker/semgrep/Dockerfile
new file mode 100644
index 0000000000..619c2058cf
--- /dev/null
+++ b/docker/semgrep/Dockerfile
@@ -0,0 +1 @@
+FROM semgrep/semgrep:1.148.0
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index bd0fae8d10..41857482ac 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -12,7 +12,8 @@ server {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Connection 'upgrade';
- proxy_set_header Host $host;
+ # Reason: Standard reverse proxy configuration; Host header forwarding is required for routing.
+ proxy_set_header Host $host; # nosemgrep: generic.nginx.security.request-host-used.request-host-used
proxy_set_header Upgrade $http_upgrade;
proxy_cache_bypass $http_upgrade;
}
diff --git a/frontend/package.json b/frontend/package.json
index 7238ce8fa6..8704ef1e46 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -46,6 +46,7 @@
"framer-motion": "^12.29.0",
"graphql": "^16.12.0",
"ics": "^3.8.1",
+ "isomorphic-dompurify": "^2.35.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lodash": "^4.17.23",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 9ed6f3c381..31f84706fc 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -96,6 +96,9 @@ importers:
ics:
specifier: ^3.8.1
version: 3.8.1
+ isomorphic-dompurify:
+ specifier: ^2.35.0
+ version: 2.35.0
leaflet:
specifier: ^1.9.4
version: 1.9.4
@@ -301,6 +304,9 @@ importers:
packages:
+ '@acemir/cssom@0.9.31':
+ resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
+
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
@@ -342,6 +348,15 @@ packages:
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
+ '@asamuzakjp/css-color@4.1.1':
+ resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==}
+
+ '@asamuzakjp/dom-selector@6.7.6':
+ resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
'@axe-core/react@4.11.0':
resolution: {integrity: sha512-ko5hYRmdLzbsxagsb0u3GD8IqtZa+vUJZ4K4+z8qjsHGrHBNBps5Sy0EIfSL8whzyIKBQ3xYa1reK1QenIAkyw==}
@@ -548,6 +563,10 @@ packages:
peerDependencies:
'@csstools/css-tokenizer': ^3.0.4
+ '@csstools/css-syntax-patches-for-csstree@1.0.25':
+ resolution: {integrity: sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==}
+ engines: {node: '>=18'}
+
'@csstools/css-tokenizer@3.0.4':
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
@@ -611,6 +630,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@exodus/bytes@1.9.0':
+ resolution: {integrity: sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@fastify/busboy@3.2.0':
resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
@@ -3901,6 +3929,9 @@ packages:
resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==}
engines: {node: '>=10.0.0'}
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@@ -4190,6 +4221,10 @@ packages:
csp_evaluator@1.1.5:
resolution: {integrity: sha512-EL/iN9etCTzw/fBnp0/uj0f5BOOGvZut2mzsiiBZ/FdT6gFQCKRO/tmcKOxn5drWZ2Ndm/xBb1SI4zwWbGtmIw==}
+ css-tree@3.1.0:
+ resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
@@ -4197,6 +4232,10 @@ packages:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
+ cssstyle@5.3.7:
+ resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
+ engines: {node: '>=20'}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -4215,6 +4254,10 @@ packages:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
+ data-urls@6.0.1:
+ resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==}
+ engines: {node: '>=20'}
+
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -5033,6 +5076,10 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -5343,6 +5390,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isomorphic-dompurify@2.35.0:
+ resolution: {integrity: sha512-a9+LQqylQCU8f1zmsYmg2tfrbdY2YS/Hc+xntcq/mDI2MY3Q108nq8K23BWDIg6YGC5JsUMC15fj2ZMqCzt/+A==}
+ engines: {node: '>=20.19.5'}
+
isomorphic-fetch@3.0.0:
resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==}
@@ -5575,6 +5626,15 @@ packages:
canvas:
optional: true
+ jsdom@27.4.0:
+ resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -5853,6 +5913,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdn-data@2.12.2:
+ resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
+
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -6232,6 +6295,9 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+ parse5@8.0.0:
+ resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
+
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -7104,6 +7170,9 @@ packages:
tldts-core@6.1.86:
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
+ tldts-core@7.0.19:
+ resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==}
+
tldts-icann@6.1.86:
resolution: {integrity: sha512-NFxmRT2lAEMcCOBgeZ0NuM0zsK/xgmNajnY6n4S1mwAKocft2s2ise1O3nQxrH3c+uY6hgHUV9GGNVp7tUE4Sg==}
@@ -7111,6 +7180,10 @@ packages:
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
hasBin: true
+ tldts@7.0.19:
+ resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
+ hasBin: true
+
tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
@@ -7137,6 +7210,10 @@ packages:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
engines: {node: '>=16'}
+ tough-cookie@6.0.0:
+ resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
+ engines: {node: '>=16'}
+
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@@ -7144,6 +7221,10 @@ packages:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -7414,6 +7495,10 @@ packages:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
webpack-sources@3.3.3:
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
engines: {node: '>=10.13.0'}
@@ -7443,10 +7528,18 @@ packages:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
whatwg-url@14.2.0:
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
engines: {node: '>=18'}
+ whatwg-url@15.1.0:
+ resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
+ engines: {node: '>=20'}
+
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -7615,6 +7708,8 @@ packages:
snapshots:
+ '@acemir/cssom@0.9.31': {}
+
'@adobe/css-tools@4.4.4': {}
'@alloc/quick-lru@5.2.0': {}
@@ -7669,6 +7764,24 @@ snapshots:
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
+ '@asamuzakjp/css-color@4.1.1':
+ dependencies:
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ lru-cache: 11.2.4
+
+ '@asamuzakjp/dom-selector@6.7.6':
+ dependencies:
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.1.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.2.4
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
'@axe-core/react@4.11.0':
dependencies:
axe-core: 4.11.1
@@ -7892,6 +8005,8 @@ snapshots:
dependencies:
'@csstools/css-tokenizer': 3.0.4
+ '@csstools/css-syntax-patches-for-csstree@1.0.25': {}
+
'@csstools/css-tokenizer@3.0.4': {}
'@emnapi/core@1.8.1':
@@ -7973,6 +8088,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
+ '@exodus/bytes@1.9.0': {}
+
'@fastify/busboy@3.2.0': {}
'@formatjs/ecma402-abstract@2.3.6':
@@ -12497,6 +12614,10 @@ snapshots:
basic-ftp@5.1.0: {}
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
binary-extensions@2.3.0: {}
body-parser@1.20.4:
@@ -12840,6 +12961,11 @@ snapshots:
csp_evaluator@1.1.5: {}
+ css-tree@3.1.0:
+ dependencies:
+ mdn-data: 2.12.2
+ source-map-js: 1.2.1
+
css.escape@1.5.1: {}
cssstyle@4.6.0:
@@ -12847,6 +12973,13 @@ snapshots:
'@asamuzakjp/css-color': 3.2.0
rrweb-cssom: 0.8.0
+ cssstyle@5.3.7:
+ dependencies:
+ '@asamuzakjp/css-color': 4.1.1
+ '@csstools/css-syntax-patches-for-csstree': 1.0.25
+ css-tree: 3.1.0
+ lru-cache: 11.2.4
+
csstype@3.2.3: {}
damerau-levenshtein@1.0.8: {}
@@ -12860,6 +12993,11 @@ snapshots:
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
+ data-urls@6.0.1:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 15.1.0
+
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -13813,6 +13951,12 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.9.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
html-escaper@2.0.2: {}
http-errors@2.0.1:
@@ -14129,6 +14273,17 @@ snapshots:
isexe@2.0.0: {}
+ isomorphic-dompurify@2.35.0:
+ dependencies:
+ dompurify: 3.3.1
+ jsdom: 27.4.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+ - bufferutil
+ - canvas
+ - supports-color
+ - utf-8-validate
+
isomorphic-fetch@3.0.0:
dependencies:
node-fetch: 2.7.0
@@ -14587,6 +14742,34 @@ snapshots:
- supports-color
- utf-8-validate
+ jsdom@27.4.0:
+ dependencies:
+ '@acemir/cssom': 0.9.31
+ '@asamuzakjp/dom-selector': 6.7.6
+ '@exodus/bytes': 1.9.0
+ cssstyle: 5.3.7
+ data-urls: 6.0.1
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ is-potential-custom-element-name: 1.0.1
+ parse5: 8.0.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 4.0.0
+ whatwg-url: 15.1.0
+ ws: 8.19.0
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
@@ -14872,6 +15055,8 @@ snapshots:
math-intrinsics@1.1.0: {}
+ mdn-data@2.12.2: {}
+
mdurl@2.0.0: {}
media-typer@0.3.0: {}
@@ -15228,6 +15413,10 @@ snapshots:
dependencies:
entities: 6.0.1
+ parse5@8.0.0:
+ dependencies:
+ entities: 6.0.1
+
parseurl@1.3.3: {}
pascal-case@3.1.2:
@@ -16170,6 +16359,8 @@ snapshots:
tldts-core@6.1.86: {}
+ tldts-core@7.0.19: {}
+
tldts-icann@6.1.86:
dependencies:
tldts-core: 6.1.86
@@ -16178,6 +16369,10 @@ snapshots:
dependencies:
tldts-core: 6.1.86
+ tldts@7.0.19:
+ dependencies:
+ tldts-core: 7.0.19
+
tmp@0.0.33:
dependencies:
os-tmpdir: 1.0.2
@@ -16200,12 +16395,20 @@ snapshots:
dependencies:
tldts: 6.1.86
+ tough-cookie@6.0.0:
+ dependencies:
+ tldts: 7.0.19
+
tr46@0.0.3: {}
tr46@5.1.1:
dependencies:
punycode: 2.3.1
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
tree-kill@1.2.2: {}
ts-api-utils@2.4.0(typescript@5.9.3):
@@ -16485,6 +16688,8 @@ snapshots:
webidl-conversions@7.0.0: {}
+ webidl-conversions@8.0.1: {}
+
webpack-sources@3.3.3: {}
webpack-virtual-modules@0.5.0: {}
@@ -16529,11 +16734,18 @@ snapshots:
whatwg-mimetype@4.0.0: {}
+ whatwg-mimetype@5.0.0: {}
+
whatwg-url@14.2.0:
dependencies:
tr46: 5.1.1
webidl-conversions: 7.0.0
+ whatwg-url@15.1.0:
+ dependencies:
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
diff --git a/frontend/src/app/organizations/[organizationKey]/layout.tsx b/frontend/src/app/organizations/[organizationKey]/layout.tsx
index e35abb8273..d148c6c501 100644
--- a/frontend/src/app/organizations/[organizationKey]/layout.tsx
+++ b/frontend/src/app/organizations/[organizationKey]/layout.tsx
@@ -1,3 +1,4 @@
+import DOMPurify from 'isomorphic-dompurify'
import { Metadata } from 'next'
import Script from 'next/script'
import React from 'react'
@@ -98,8 +99,20 @@ export default async function OrganizationDetailsLayout({
params: Promise<{ organizationKey: string }>
}>) {
const { organizationKey } = await params
+
+ if (!/^[a-zA-Z0-9._-]+$/.test(organizationKey)) {
+ return (
+
+ Invalid Organization Key
+
+ )
+ }
const structuredData = await generateOrganizationStructuredData(organizationKey)
+ const jsonLdString = structuredData
+ ? DOMPurify.sanitize(JSON.stringify(structuredData, null, 2))
+ : null
+
// Fetch organization name for breadcrumb
const { data } = await apolloClient.query({
query: GetOrganizationMetadataDocument,
@@ -109,12 +122,12 @@ export default async function OrganizationDetailsLayout({
return (
- {structuredData && (
+ {jsonLdString && (
)}
diff --git a/frontend/src/components/StructuredDataScript.tsx b/frontend/src/components/StructuredDataScript.tsx
index 3e70b99ad7..be6c755600 100644
--- a/frontend/src/components/StructuredDataScript.tsx
+++ b/frontend/src/components/StructuredDataScript.tsx
@@ -1,3 +1,4 @@
+import DOMPurify from 'isomorphic-dompurify'
import React from 'react'
import { ProfilePageStructuredData } from 'types/profilePageStructuredData'
@@ -5,14 +6,14 @@ interface StructuredDataScriptProps {
data: ProfilePageStructuredData
}
-// dangerouslySetInnerHTML injects the JSON data as a script tag.
const StructuredDataScript: React.FC = ({ data }) => {
+ const cleanData = DOMPurify.sanitize(JSON.stringify(data, null, 2))
return (
)
diff --git a/proxy/production.conf b/proxy/production.conf
index 08d632360d..f92b9d00dd 100644
--- a/proxy/production.conf
+++ b/proxy/production.conf
@@ -23,7 +23,8 @@ server {
location / {
proxy_pass http://production-nest-frontend:3000;
- proxy_set_header Host $host;
+ # Reason: Standard reverse proxy configuration; Host header forwarding is required for routing.
+ proxy_set_header Host $host; # nosemgrep: generic.nginx.security.request-host-used.request-host-used
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
@@ -39,7 +40,8 @@ server {
location ~ ^/(a|api/v0|csrf|graphql|idx|integrations|sitemap(?:\.xml)?|status)(/|$) {
proxy_pass http://production-nest-backend:8000;
- proxy_set_header Host $host;
+ # Reason: Standard reverse proxy configuration; Host header forwarding is required for routing.
+ proxy_set_header Host $host; # nosemgrep: generic.nginx.security.request-host-used.request-host-used
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
@@ -77,6 +79,7 @@ server {
}
location / {
- return 301 https://$host$request_uri;
+ # Reason: Standard reverse proxy configuration; Host header forwarding is required for routing.
+ return 301 https://$host$request_uri; # nosemgrep: generic.nginx.security.request-host-used.request-host-used
}
}
diff --git a/proxy/redirects.conf b/proxy/redirects.conf
index 2b730239b4..0b704c2b7e 100644
--- a/proxy/redirects.conf
+++ b/proxy/redirects.conf
@@ -7,5 +7,8 @@ location = /snapshots {
}
location ^~ /snapshots/ {
- rewrite ^/snapshots/(.*)$ /community/snapshots/$1 permanent;
+ # Relative redirect is safe: all HTTP traffic is redirected to HTTPS at the server level
+ # (port 80 -> 443) before reaching this location block. Relative redirects preserve
+ # the incoming scheme, so HTTPS requests remain HTTPS.
+ rewrite ^/snapshots/(.*)$ /community/snapshots/$1 permanent; # NOSEMGREP: generic.nginx.security.insecure-redirect.insecure-redirect
}
diff --git a/proxy/staging.conf b/proxy/staging.conf
index d1fc41a1ee..54201392c1 100644
--- a/proxy/staging.conf
+++ b/proxy/staging.conf
@@ -23,7 +23,8 @@ server {
location / {
proxy_pass http://staging-nest-frontend:3000;
- proxy_set_header Host $host;
+ # Reason: Standard reverse proxy configuration; Host header forwarding is required for routing.
+ proxy_set_header Host $host; # nosemgrep: generic.nginx.security.request-host-used.request-host-used
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
@@ -39,7 +40,8 @@ server {
location ~ ^/(a|api/v0|csrf|graphql|idx|integrations|sitemap(?:\.xml)?|status)(/|$) {
proxy_pass http://staging-nest-backend:8000;
- proxy_set_header Host $host;
+ # Reason: Standard reverse proxy configuration; Host header forwarding is required for routing.
+ proxy_set_header Host $host; # nosemgrep: generic.nginx.security.request-host-used.request-host-used
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
@@ -80,6 +82,7 @@ server {
}
location / {
- return 301 https://$host$request_uri;
+ # Reason: Standard reverse proxy configuration; Host header forwarding is required for routing.
+ return 301 https://$host$request_uri; # nosemgrep: generic.nginx.security.request-host-used.request-host-used
}
}