Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
- `cfn-secret-parameter-default` — 시크릿 성격 이름의 Parameter에 리터럴 `Default` 값 커밋(`{{resolve:...}}` 동적 참조는 안전으로 통과). HIGH.
- `tests/test_cloudformation_rules.py` — 룰별 양성/음성 패턴 테스트, severity 검증, e2e 스캔(오염 템플릿에서 6종 전부 발화, 안전 템플릿 0건), k8s/compose/GitHub Actions look-alike 음성 테스트 포함(총 29건).

### 추가
- Vue/Svelte/Nuxt 프런트엔드 룰 팩 `scanner/rules/vue-svelte.yml` 6종 추가(고정밀, 경로 스코프 적용). React(`dangerouslySetInnerHTML`)만 다루던 프런트엔드 XSS·시크릿 노출 탐지를 Vue·Svelte 생태계로 확장합니다.
- `vue-v-html-usage` — `.vue` 템플릿의 `v-html` 디렉티브(raw HTML 렌더링, XSS 싱크). HIGH.
- `svelte-html-tag-usage` — `.svelte` 템플릿의 raw HTML 태그(이스케이프 없이 마크업 주입). HIGH.
- `nuxt-public-env-secret` — 시크릿 성격 이름의 환경 변수에 NUXT_PUBLIC_ 접두사 사용(클라이언트 번들에 노출). CRITICAL.
- `vite-env-secret-exposed` — `.env*` 파일에서 시크릿 성격 이름의 변수에 VITE_ 접두사 사용(Vite가 클라이언트 번들에 인라인). CRITICAL.
- `sveltekit-private-env-in-client` — `.svelte` 컴포넌트에서 SvelteKit private env(`$env/*/private`) import. HIGH.
- `sveltekit-csrf-origin-check-disabled` — svelte.config에서 CSRF origin 검사 비활성화. HIGH.
- `tests/test_vue_svelte_rules.py`: 룰별 양성·음성 정밀도, severity, 경로 스코프, e2e 스캔(취약/안전 프로젝트) 검증.

### 추가
- `appguardrail fix` 명령 — 안전하고 결정적인 자동 수정을 적용합니다(기본 dry-run diff, `--apply`로 기록). 의미를 바꾸지 않는 순수 additive 변환만 수행하며, 첫 변환으로 외부 `target="_blank"` 링크에 `rel="noopener noreferrer"`를 추가합니다(reverse tabnabbing 방지). 동작을 바꾸는 수정(시크릿→env 등)은 위험하므로 자동 적용하지 않고 fix-pack 프롬프트로 남깁니다. scan→fix→verify 루프를 안전하게 닫습니다.
- `appguardrail serve` — 멀티테넌트 **control-plane API**(스캔 인제스트 + 히스토리). 일회성 CLI를 넘어, CI가 매 스캔의 `appguardrail.findings.v1`을 org별 API 키로 영속 저장하고 시간에 따른 추이를 조회할 수 있는 지속형 백본입니다. stdlib(sqlite3 + http.server)만 사용하며 org별 테넌트 격리를 강제합니다.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ Detects:
- API routes missing authentication
- Public Firebase rules (`read/write: true`)
- Dangerous CORS settings (`origin: "*"`)
- Vue/Svelte/Nuxt frontend risks (`v-html` and raw HTML template sinks,
client-exposed `VITE_`/`NUXT_PUBLIC_` secret env vars, SvelteKit private env
imports in components, disabled SvelteKit CSRF origin checks)
- Missing Stripe webhook signature verification
- Unprotected admin routes
- Risky file upload handlers
Expand Down
101 changes: 101 additions & 0 deletions scanner/rules/vue-svelte.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
rules:
- id: vue-v-html-usage
patterns:
- pattern-regex: '(?i)\sv-html\s*='
message: |
Vue template uses the v-html directive, which renders raw HTML and is
the Vue equivalent of dangerouslySetInnerHTML. If the bound value
contains user-controlled content this is an XSS sink. Render text with
mustache interpolation instead, or sanitize the HTML with a library
like DOMPurify before binding it.
severity: HIGH
languages: [generic]
cwe: [CWE-79]
owasp: [A03:2021]
paths:
include:
- "**/*.vue"

- id: svelte-html-tag-usage
patterns:
- pattern-regex: '\{@html\s'
message: |
Svelte template uses the raw HTML tag, which injects markup without
escaping. Svelte does not sanitize these expressions, so any
user-controlled value becomes an XSS sink. Render plain expressions
instead, or sanitize the HTML with a library like DOMPurify before
passing it to the template.
severity: HIGH
languages: [generic]
cwe: [CWE-79]
owasp: [A03:2021]
paths:
include:
- "**/*.svelte"

- id: nuxt-public-env-secret
patterns:
- pattern-regex: '\bNUXT_PUBLIC_[A-Z0-9_]*(?:SECRET|PASSWORD|PRIVATE_KEY|SERVICE_ROLE|DATABASE_URL|ACCESS_KEY)[A-Z0-9_]*'
message: |
A secret-named environment variable uses the NUXT_PUBLIC_ prefix.
Nuxt embeds every runtimeConfig.public value in the client bundle, so
this value ships to every visitor's browser. Drop the public prefix,
keep the value in server-only runtimeConfig, and rotate the exposed
credential.
severity: CRITICAL
languages: [generic]
cwe: [CWE-540]
owasp: [A02:2021]

- id: vite-env-secret-exposed
patterns:
- pattern-regex: '\bVITE_[A-Z0-9_]*(?:SECRET|PASSWORD|PRIVATE_KEY|SERVICE_ROLE|ACCESS_KEY)[A-Z0-9_]*\s*='
message: |
A secret-named environment variable uses the VITE_ prefix in an env
file. Vite statically inlines every VITE_ variable into the client
JavaScript bundle, so this value is readable by anyone who loads the
app. Remove the VITE_ prefix, move the value to a server-side
environment, and rotate the exposed credential.
severity: CRITICAL
languages: [generic]
cwe: [CWE-540]
owasp: [A02:2021]
paths:
include:
- "**/.env*"

- id: sveltekit-private-env-in-client
patterns:
- pattern-regex: 'from\s+[''"]\$env/(?:static|dynamic)/private[''"]'
message: |
A Svelte component imports SvelteKit private environment variables.
Components can be server-rendered and then hydrated in the browser, so
private env values must never be referenced from .svelte files. Read
the value in a server-only module (+page.server.ts, +layout.server.ts,
or hooks.server.ts) and pass only the data the client needs.
severity: HIGH
languages: [generic]
cwe: [CWE-200]
owasp: [A02:2021]
paths:
include:
- "**/*.svelte"

- id: sveltekit-csrf-origin-check-disabled
patterns:
- pattern-regex: '\bcheckOrigin\s*:\s*false\b'
message: |
SvelteKit CSRF origin checking is disabled in svelte.config. With
checkOrigin turned off, SvelteKit accepts cross-origin form submissions
to your form actions, enabling CSRF attacks against authenticated
users. Re-enable the default origin check and, if a trusted external
origin must post forms, validate it explicitly in a server hook.
severity: HIGH
languages: [generic]
cwe: [CWE-352]
owasp: [A01:2021]
paths:
include:
- "**/svelte.config.js"
- "**/svelte.config.ts"
- "**/svelte.config.mjs"
238 changes: 238 additions & 0 deletions tests/test_vue_svelte_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
"""Coverage tests for the Vue / Svelte / Nuxt frontend rule pack (6 rules)."""

import pytest

from scanner.cli.appguardrail import (
SCAN_RULES,
_path_allowed_by_rule,
_scan_file,
)

_BY_ID = {}
for _r in SCAN_RULES:
_BY_ID.setdefault(_r["id"], _r)

RULE_IDS = [
"vue-v-html-usage",
"svelte-html-tag-usage",
"nuxt-public-env-secret",
"vite-env-secret-exposed",
"sveltekit-private-env-in-client",
"sveltekit-csrf-origin-check-disabled",
]


def _rule(rule_id):
assert rule_id in _BY_ID, f"rule not loaded: {rule_id}"
return _BY_ID[rule_id]


# Secret-shaped env names are assembled at runtime so the repository never
# contains a literal client-exposed secret variable name.
_NUXT_PUBLIC = "NUXT_" + "PUBLIC_"
_VITE = "VI" + "TE_"

CASES = {
"vue-v-html-usage": (
[
'<div v-html="userContent"></div>',
'<span\n v-html="renderedMarkdown"\n></span>',
"<article v-html='post.body'></article>",
],
[
'<div data-v-html="x"></div>',
"<p>{{ userContent }}</p>",
"// v-html is dangerous, do not use it",
'<div :title="vHtml"></div>',
],
),
"svelte-html-tag-usage": (
["{@html body}", "{@html marked(post.content)}", "<div>{@html\n data}</div>"],
[
"{@const rendered = escape(body)}",
"<p>{body}</p>",
"<!-- the raw html tag is unsafe -->",
"{@render children()}",
],
),
"nuxt-public-env-secret": (
[
_NUXT_PUBLIC + "STRIPE_SECRET_KEY",
_NUXT_PUBLIC + "SUPABASE_SERVICE_ROLE_KEY",
_NUXT_PUBLIC + "DB_PASSWORD",
_NUXT_PUBLIC + "DATABASE_URL",
],
[
_NUXT_PUBLIC + "API_BASE_URL",
_NUXT_PUBLIC + "SITE_NAME",
"NUXT_STRIPE_SECRET_KEY",
"MY" + _NUXT_PUBLIC + "JWT_SECRET",
],
),
"vite-env-secret-exposed": (
[
_VITE + "API_SECRET=abc123def",
_VITE + "SUPABASE_SERVICE_ROLE_KEY = value",
_VITE + "DB_PASSWORD=hunter2hunter2",
_VITE + "AWS_ACCESS_KEY_ID=placeholder",
],
[
_VITE + "API_BASE_URL=https://api.example.com",
_VITE + "PUBLIC_POSTHOG_KEY=phc_public",
"STRIPE_SECRET_KEY=only-server-side",
"INVITE_PASSWORD_RESET=on",
],
),
"sveltekit-private-env-in-client": (
[
"import { API_KEY } from '$env/static/private';",
'import { env } from "$env/dynamic/private";',
],
[
"import { PUBLIC_BASE_URL } from '$env/static/public';",
"import { env } from '$env/dynamic/public';",
"import { browser } from '$app/environment';",
],
),
"sveltekit-csrf-origin-check-disabled": (
["csrf: { checkOrigin: false }", "checkOrigin:false,"],
[
"csrf: { checkOrigin: true }",
"const shouldCheckOrigin = false;",
"// checkOrigin defaults to true",
],
),
}


@pytest.mark.parametrize("rule_id", CASES.keys())
def test_rule_precision(rule_id):
rule = _rule(rule_id)
positives, negatives = CASES[rule_id]
assert len(positives) >= 2 and len(negatives) >= 2
for s in positives:
assert rule["pattern"].search(s), f"{rule_id} should match: {s!r}"
for s in negatives:
assert not rule["pattern"].search(s), f"{rule_id} false-positive on: {s!r}"


def test_severities():
assert _rule("vue-v-html-usage")["severity"] == "HIGH"
assert _rule("svelte-html-tag-usage")["severity"] == "HIGH"
assert _rule("nuxt-public-env-secret")["severity"] == "CRITICAL"
assert _rule("vite-env-secret-exposed")["severity"] == "CRITICAL"
assert _rule("sveltekit-private-env-in-client")["severity"] == "HIGH"
assert _rule("sveltekit-csrf-origin-check-disabled")["severity"] == "HIGH"


PATH_CASES = {
"vue-v-html-usage": (
["src/components/Comment.vue", "App.vue"],
["src/render.js", "src/Comment.jsx"],
),
"svelte-html-tag-usage": (
["src/routes/+page.svelte", "Widget.svelte"],
["src/lib/render.ts"],
),
"vite-env-secret-exposed": (
[".env", ".env.production", "apps/web/.env.local"],
["src/config.ts", "vite.config.ts"],
),
"sveltekit-private-env-in-client": (
["src/routes/+layout.svelte", "src/lib/Card.svelte"],
["src/routes/+page.server.ts", "src/hooks.server.ts"],
),
"sveltekit-csrf-origin-check-disabled": (
["svelte.config.js", "apps/site/svelte.config.ts", "svelte.config.mjs"],
["vite.config.js", "src/kit.ts"],
),
}


@pytest.mark.parametrize("rule_id", PATH_CASES.keys())
def test_path_scoping(rule_id):
rule = _rule(rule_id)
include = rule["include_paths"]
exclude = rule["exclude_paths"]
assert include, f"{rule_id} should be path-scoped"
allowed, denied = PATH_CASES[rule_id]
for path in allowed:
assert _path_allowed_by_rule(path, include, exclude), (
f"{rule_id} should apply to {path}"
)
for path in denied:
assert not _path_allowed_by_rule(path, include, exclude), (
f"{rule_id} should not apply to {path}"
)


def test_nuxt_rule_is_not_path_scoped():
rule = _rule("nuxt-public-env-secret")
assert rule["include_paths"] == []
assert rule["extensions"] is None


def _scan_project(base):
findings = []
for path in sorted(base.rglob("*")):
if path.is_file():
findings.extend(_scan_file(path, base))
return findings


def test_e2e_scan_flags_vulnerable_frontend(tmp_path):
(tmp_path / "src" / "routes").mkdir(parents=True)
(tmp_path / "src" / "Comment.vue").write_text(
'<template>\n <div v-html="comment.body"></div>\n</template>\n',
encoding="utf-8",
)
(tmp_path / "src" / "Post.svelte").write_text(
"<script>export let body;</script>\n{@html body}\n",
encoding="utf-8",
)
(tmp_path / "src" / "routes" / "+page.svelte").write_text(
"<script>\nimport { DB_URL } from '$env/static/private';\n</script>\n",
encoding="utf-8",
)
(tmp_path / "svelte.config.js").write_text(
"export default { kit: { csrf: { checkOrigin: false } } };\n",
encoding="utf-8",
)
(tmp_path / ".env").write_text(
_VITE + "API_SECRET=abc123def456\n" + _NUXT_PUBLIC + "JWT_SECRET=change-me\n",
encoding="utf-8",
)

findings = _scan_project(tmp_path)
hits = {(f["rule_id"], f["file"]) for f in findings}

assert ("vue-v-html-usage", "src/Comment.vue") in hits
assert ("svelte-html-tag-usage", "src/Post.svelte") in hits
assert ("sveltekit-private-env-in-client", "src/routes/+page.svelte") in hits
assert ("sveltekit-csrf-origin-check-disabled", "svelte.config.js") in hits
assert ("vite-env-secret-exposed", ".env") in hits
assert ("nuxt-public-env-secret", ".env") in hits


def test_e2e_scan_clean_frontend_has_no_findings(tmp_path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "Comment.vue").write_text(
"<template>\n <p>{{ comment.body }}</p>\n</template>\n",
encoding="utf-8",
)
(tmp_path / "src" / "Post.svelte").write_text(
"<script>export let body;</script>\n<p>{body}</p>\n",
encoding="utf-8",
)
(tmp_path / "svelte.config.js").write_text(
"export default { kit: { csrf: { checkOrigin: true } } };\n",
encoding="utf-8",
)
(tmp_path / ".env").write_text(
_VITE + "API_BASE_URL=https://api.example.com\n",
encoding="utf-8",
)

findings = _scan_project(tmp_path)
flagged = {f["rule_id"] for f in findings} & set(RULE_IDS)
assert flagged == set(), f"clean project false-positives: {flagged}"
Loading