diff --git a/CHANGELOG.md b/CHANGELOG.md index a026dfb..779e944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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별 테넌트 격리를 강제합니다. diff --git a/README.md b/README.md index e857149..3e2f645 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,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 diff --git a/scanner/rules/vue-svelte.yml b/scanner/rules/vue-svelte.yml new file mode 100644 index 0000000..e39b591 --- /dev/null +++ b/scanner/rules/vue-svelte.yml @@ -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" diff --git a/tests/test_vue_svelte_rules.py b/tests/test_vue_svelte_rules.py new file mode 100644 index 0000000..3755ff7 --- /dev/null +++ b/tests/test_vue_svelte_rules.py @@ -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": ( + [ + '
', + '', + "
", + ], + [ + '
', + "

{{ userContent }}

", + "// v-html is dangerous, do not use it", + '
', + ], + ), + "svelte-html-tag-usage": ( + ["{@html body}", "{@html marked(post.content)}", "
{@html\n data}
"], + [ + "{@const rendered = escape(body)}", + "

{body}

", + "", + "{@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( + '\n', + encoding="utf-8", + ) + (tmp_path / "src" / "Post.svelte").write_text( + "\n{@html body}\n", + encoding="utf-8", + ) + (tmp_path / "src" / "routes" / "+page.svelte").write_text( + "\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( + "\n", + encoding="utf-8", + ) + (tmp_path / "src" / "Post.svelte").write_text( + "\n

{body}

\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}"