preship 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- preship-0.1.0/PKG-INFO +105 -0
- preship-0.1.0/README.md +93 -0
- preship-0.1.0/pyproject.toml +24 -0
- preship-0.1.0/setup.cfg +4 -0
- preship-0.1.0/src/ohs_preflight/__init__.py +3 -0
- preship-0.1.0/src/ohs_preflight/cli.py +254 -0
- preship-0.1.0/src/ohs_preflight/core.py +221 -0
- preship-0.1.0/src/ohs_preflight/patch.py +209 -0
- preship-0.1.0/src/ohs_preflight/verify.py +76 -0
- preship-0.1.0/src/preship.egg-info/PKG-INFO +105 -0
- preship-0.1.0/src/preship.egg-info/SOURCES.txt +13 -0
- preship-0.1.0/src/preship.egg-info/dependency_links.txt +1 -0
- preship-0.1.0/src/preship.egg-info/entry_points.txt +2 -0
- preship-0.1.0/src/preship.egg-info/requires.txt +2 -0
- preship-0.1.0/src/preship.egg-info/top_level.txt +1 -0
preship-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: preship
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Preship: FastAPI 스테이징 URL을 퍼징해 터지는 입력과 패턴별 AI 수정 프롬프트를 내주는 출시 전 진단 CLI.
|
|
5
|
+
Author: Preship
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Keywords: fastapi,testing,fuzzing,openapi,api,preflight
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: schemathesis==4.20.3
|
|
11
|
+
Requires-Dist: anthropic==0.105.2
|
|
12
|
+
|
|
13
|
+
# Preship
|
|
14
|
+
|
|
15
|
+
**FastAPI 스테이징 앱을 출시 전에 자동으로 두드려, 터지는 입력과 고치는 법을 찾아주는 CLI 도구.**
|
|
16
|
+
|
|
17
|
+
스테이징 URL 하나만 주면 모든 엔드포인트에 입력을 퍼징해서:
|
|
18
|
+
|
|
19
|
+
- **무엇이 터지는지** — 500 에러, 문서화 안 된 응답, 스키마 불일치를 패턴별로 묶어서 보여주고
|
|
20
|
+
- **어떻게 고치는지** — 그대로 복사해 *당신이 쓰는 AI*(ChatGPT·Claude·Gemini 등 아무거나)에
|
|
21
|
+
붙여넣을 **수정 프롬프트**를 패턴마다 1개씩 만들어 줍니다.
|
|
22
|
+
|
|
23
|
+
API 키 필요 없습니다. 결함을 찾고 수정 프롬프트를 받는 데까지 전부 키 없이 됩니다.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 1. 설치
|
|
28
|
+
|
|
29
|
+
Python 3.10 이상이 필요합니다.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install preship
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
설치되면 `preship` 명령이 생깁니다.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
preship --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 2. 스테이징 URL 준비 + 소유권 확인 라우트 달기
|
|
42
|
+
|
|
43
|
+
Preship은 **당신이 소유한 URL만** 스캔합니다 (남의 서버를 두드리지 못하게). 그래서 스캔 전에
|
|
44
|
+
"이 URL은 내 것"임을 한 번 증명해야 합니다 — 확인용 라우트 한 개를 앱에 다는 게 전부입니다.
|
|
45
|
+
|
|
46
|
+
먼저 스캔을 한 번 실행하면, Preship이 **당신만의 토큰**과 **그대로 붙여넣을 라우트 코드**를
|
|
47
|
+
출력해 줍니다. 그 토큰을 아래 스니펫에 끼워 앱에 추가하고 재배포하세요.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# 당신의 FastAPI 앱에 이 라우트 하나만 추가 (app = FastAPI() 아래 아무 곳)
|
|
51
|
+
from fastapi.responses import PlainTextResponse
|
|
52
|
+
|
|
53
|
+
@app.get("/.well-known/preflight-verify")
|
|
54
|
+
def preflight_verify():
|
|
55
|
+
return PlainTextResponse("여기에 scan이 알려준 당신의 토큰을 붙여넣으세요")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> 토큰은 첫 스캔 실행이 정확히 알려주고, 라우트 코드도 함께 출력합니다. 추측할 필요 없이
|
|
59
|
+
> 출력된 걸 그대로 복사해 붙여넣으면 됩니다. (토큰은 URL마다 고정이라 한 번만 달면 됩니다.)
|
|
60
|
+
|
|
61
|
+
## 3. 스캔 실행
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
preship scan https://your-staging.example.com
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
스테이징에 인증이 걸려 있으면 헤더를 함께 넘기세요:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
preship scan https://your-staging.example.com --header "Authorization: Bearer <토큰>"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 4. 결과 읽는 법
|
|
74
|
+
|
|
75
|
+
출력은 두 부분입니다.
|
|
76
|
+
|
|
77
|
+
**(1) 결함 리포트** — 같은 종류의 문제를 패턴으로 묶고, `[HIGH]`(서버가 500으로 터짐) /
|
|
78
|
+
`[LOW]`(응답 계약 불일치)로 심각도를 표시합니다. 각 패턴마다 영향받는 엔드포인트 목록과
|
|
79
|
+
실제로 보냈던 요청 → 받은 응답이 따라옵니다.
|
|
80
|
+
|
|
81
|
+
**(2) AI 수정 프롬프트** — 패턴마다 프롬프트 블록이 1개씩 나옵니다. **블록을 통째로 복사해
|
|
82
|
+
당신이 쓰는 AI에 그대로 붙여넣으면**, 원인 추정 + FastAPI 수정 패치 + 설명을 받습니다.
|
|
83
|
+
프롬프트는 당신 소스 코드를 담지 않고(스캐너가 관측한 동작만), 특정 AI에 묶이지 않습니다.
|
|
84
|
+
|
|
85
|
+
> 받은 패치를 적용하고 다시 `preship scan` 하면 그 패턴이 사라졌는지 바로 확인할 수 있습니다.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 고급 (선택): 자동 패치 초안
|
|
90
|
+
|
|
91
|
+
자동 패치 초안까지 원하면, **당신의** Anthropic API 키를 **당신의** 환경변수에 넣고 스캔하세요.
|
|
92
|
+
넣지 않아도 위의 수정 프롬프트는 그대로 나옵니다 — 키는 어디까지나 선택입니다.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export ANTHROPIC_API_KEY="sk-ant-..." # 당신의 키. 베타 기본 경로는 키 없이 프롬프트만으로 충분합니다.
|
|
96
|
+
preship scan https://your-staging.example.com
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 개발 / 문서
|
|
102
|
+
|
|
103
|
+
설계 계약과 진행 기록은 `docs/`에 있습니다 — `docs/S0_scope.md`(범위), `docs/S1_spec.md`(데모
|
|
104
|
+
앱 스펙·채점 기준, 읽기 전용), `docs/HANDOFF_LOG.md`(빌드 로그). `fixtures/`는 테스트용 버그 앱
|
|
105
|
+
(제품 코드 아님).
|
preship-0.1.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Preship
|
|
2
|
+
|
|
3
|
+
**FastAPI 스테이징 앱을 출시 전에 자동으로 두드려, 터지는 입력과 고치는 법을 찾아주는 CLI 도구.**
|
|
4
|
+
|
|
5
|
+
스테이징 URL 하나만 주면 모든 엔드포인트에 입력을 퍼징해서:
|
|
6
|
+
|
|
7
|
+
- **무엇이 터지는지** — 500 에러, 문서화 안 된 응답, 스키마 불일치를 패턴별로 묶어서 보여주고
|
|
8
|
+
- **어떻게 고치는지** — 그대로 복사해 *당신이 쓰는 AI*(ChatGPT·Claude·Gemini 등 아무거나)에
|
|
9
|
+
붙여넣을 **수정 프롬프트**를 패턴마다 1개씩 만들어 줍니다.
|
|
10
|
+
|
|
11
|
+
API 키 필요 없습니다. 결함을 찾고 수정 프롬프트를 받는 데까지 전부 키 없이 됩니다.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. 설치
|
|
16
|
+
|
|
17
|
+
Python 3.10 이상이 필요합니다.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install preship
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
설치되면 `preship` 명령이 생깁니다.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
preship --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 2. 스테이징 URL 준비 + 소유권 확인 라우트 달기
|
|
30
|
+
|
|
31
|
+
Preship은 **당신이 소유한 URL만** 스캔합니다 (남의 서버를 두드리지 못하게). 그래서 스캔 전에
|
|
32
|
+
"이 URL은 내 것"임을 한 번 증명해야 합니다 — 확인용 라우트 한 개를 앱에 다는 게 전부입니다.
|
|
33
|
+
|
|
34
|
+
먼저 스캔을 한 번 실행하면, Preship이 **당신만의 토큰**과 **그대로 붙여넣을 라우트 코드**를
|
|
35
|
+
출력해 줍니다. 그 토큰을 아래 스니펫에 끼워 앱에 추가하고 재배포하세요.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# 당신의 FastAPI 앱에 이 라우트 하나만 추가 (app = FastAPI() 아래 아무 곳)
|
|
39
|
+
from fastapi.responses import PlainTextResponse
|
|
40
|
+
|
|
41
|
+
@app.get("/.well-known/preflight-verify")
|
|
42
|
+
def preflight_verify():
|
|
43
|
+
return PlainTextResponse("여기에 scan이 알려준 당신의 토큰을 붙여넣으세요")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> 토큰은 첫 스캔 실행이 정확히 알려주고, 라우트 코드도 함께 출력합니다. 추측할 필요 없이
|
|
47
|
+
> 출력된 걸 그대로 복사해 붙여넣으면 됩니다. (토큰은 URL마다 고정이라 한 번만 달면 됩니다.)
|
|
48
|
+
|
|
49
|
+
## 3. 스캔 실행
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
preship scan https://your-staging.example.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
스테이징에 인증이 걸려 있으면 헤더를 함께 넘기세요:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
preship scan https://your-staging.example.com --header "Authorization: Bearer <토큰>"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 4. 결과 읽는 법
|
|
62
|
+
|
|
63
|
+
출력은 두 부분입니다.
|
|
64
|
+
|
|
65
|
+
**(1) 결함 리포트** — 같은 종류의 문제를 패턴으로 묶고, `[HIGH]`(서버가 500으로 터짐) /
|
|
66
|
+
`[LOW]`(응답 계약 불일치)로 심각도를 표시합니다. 각 패턴마다 영향받는 엔드포인트 목록과
|
|
67
|
+
실제로 보냈던 요청 → 받은 응답이 따라옵니다.
|
|
68
|
+
|
|
69
|
+
**(2) AI 수정 프롬프트** — 패턴마다 프롬프트 블록이 1개씩 나옵니다. **블록을 통째로 복사해
|
|
70
|
+
당신이 쓰는 AI에 그대로 붙여넣으면**, 원인 추정 + FastAPI 수정 패치 + 설명을 받습니다.
|
|
71
|
+
프롬프트는 당신 소스 코드를 담지 않고(스캐너가 관측한 동작만), 특정 AI에 묶이지 않습니다.
|
|
72
|
+
|
|
73
|
+
> 받은 패치를 적용하고 다시 `preship scan` 하면 그 패턴이 사라졌는지 바로 확인할 수 있습니다.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 고급 (선택): 자동 패치 초안
|
|
78
|
+
|
|
79
|
+
자동 패치 초안까지 원하면, **당신의** Anthropic API 키를 **당신의** 환경변수에 넣고 스캔하세요.
|
|
80
|
+
넣지 않아도 위의 수정 프롬프트는 그대로 나옵니다 — 키는 어디까지나 선택입니다.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export ANTHROPIC_API_KEY="sk-ant-..." # 당신의 키. 베타 기본 경로는 키 없이 프롬프트만으로 충분합니다.
|
|
84
|
+
preship scan https://your-staging.example.com
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 개발 / 문서
|
|
90
|
+
|
|
91
|
+
설계 계약과 진행 기록은 `docs/`에 있습니다 — `docs/S0_scope.md`(범위), `docs/S1_spec.md`(데모
|
|
92
|
+
앱 스펙·채점 기준, 읽기 전용), `docs/HANDOFF_LOG.md`(빌드 로그). `fixtures/`는 테스트용 버그 앱
|
|
93
|
+
(제품 코드 아님).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "preship"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Preship: FastAPI 스테이징 URL을 퍼징해 터지는 입력과 패턴별 AI 수정 프롬프트를 내주는 출시 전 진단 CLI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "Preship" }]
|
|
13
|
+
keywords = ["fastapi", "testing", "fuzzing", "openapi", "api", "preflight"]
|
|
14
|
+
# CLI 런타임 의존은 schemathesis 하나(core가 동일 venv의 schemathesis 콘솔 스크립트를 서브프로세스로 구동).
|
|
15
|
+
# fastapi/uvicorn 등은 데모 타깃 실행용으로 requirements.txt에 유지.
|
|
16
|
+
dependencies = ["schemathesis==4.20.3", "anthropic==0.105.2"]
|
|
17
|
+
|
|
18
|
+
# 홈페이지/URL: 레포 private — 공개 랜딩 페이지(베타-1) 확정 후 추가 예정.
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
preship = "ohs_preflight.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
preship-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""사람용 CLI: `preship scan <URL>`.
|
|
2
|
+
|
|
3
|
+
core.scan()이 돌려준 list[Finding]를 사람이 읽게 포맷해 출력한다(문자열 직박기 아님).
|
|
4
|
+
기계용(JSON) 출력·패치 생성은 S3 — 여기 없다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
|
|
12
|
+
from ohs_preflight.core import Finding, ScanError, scan
|
|
13
|
+
from ohs_preflight.patch import Diagnosis, generate_patches, has_api_key
|
|
14
|
+
from ohs_preflight.verify import WELL_KNOWN_PATH, VerificationResult, WellKnownVerifier
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# A(c): 같은 체크가 여러 엔드포인트에서 반복되면 "패턴 1 × N 위치"로 묶어 출력.
|
|
18
|
+
# 정적(canned) 일괄수정 방향 — AI 생성 아님(S3.5 프롬프트와 무관).
|
|
19
|
+
_FIX_HINT = {
|
|
20
|
+
"not_a_server_error": "입력 검증·예외 처리로 500 제거(타입 강화·가드·표준 예외).",
|
|
21
|
+
"status_code_conformance": "반환하는 상태코드를 라우트 responses={코드: {...}}에 문서화.",
|
|
22
|
+
"response_schema_conformance": "응답이 선언된 response_model 스키마를 충족하게(모델 인스턴스 반환).",
|
|
23
|
+
"content_type_conformance": "선언된 content-type(application/json 등)에 응답을 맞춤.",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# S3.5(a): 패턴별 평문 문제 설명 — 외부 AI에 붙여넣을 프롬프트의 ①문제 문장.
|
|
27
|
+
# 진단 분류(관측된 동작)만 서술 — 유저 소스를 모르므로 원인은 상상하지 않는다.
|
|
28
|
+
_PROBLEM = {
|
|
29
|
+
"not_a_server_error": "아래 엔드포인트들이 특정 입력에서 처리되지 않은 예외로 서버 500을 반환합니다.",
|
|
30
|
+
"status_code_conformance": "아래 엔드포인트들이 OpenAPI에 문서화되지 않은 상태코드를 반환합니다.",
|
|
31
|
+
"response_schema_conformance": "아래 엔드포인트들의 응답 본문이 선언된 response_model 스키마를 충족하지 않습니다.",
|
|
32
|
+
"content_type_conformance": "아래 엔드포인트들이 선언된 content-type과 다른 형식으로 응답합니다.",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _group_by_pattern(findings: list[Finding]) -> "list[tuple[str, list[Finding]]]":
|
|
37
|
+
"""결함을 체크(패턴)별로 묶고 high 패턴 먼저 정렬해 반환.
|
|
38
|
+
|
|
39
|
+
_format(사람용 리포트)와 _format_prompts(붙여넣기용 프롬프트)가 공유 —
|
|
40
|
+
"패턴 1개 = 그룹 1개" 단위를 한 군데서만 정의한다.
|
|
41
|
+
"""
|
|
42
|
+
patterns: "OrderedDict[str, list[Finding]]" = OrderedDict()
|
|
43
|
+
for f in findings:
|
|
44
|
+
patterns.setdefault(f.check, []).append(f)
|
|
45
|
+
# high 패턴 먼저, low 뒤(그룹 내 첫 Finding의 level 기준; 그 외 등장 순서 유지)
|
|
46
|
+
return sorted(patterns.items(), key=lambda kv: 0 if kv[1][0].level == "high" else 1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _format(findings: list[Finding]) -> str:
|
|
50
|
+
if not findings:
|
|
51
|
+
return "✓ 결함 없음 (No issues found).\n"
|
|
52
|
+
|
|
53
|
+
ordered = _group_by_pattern(findings)
|
|
54
|
+
patterns = dict(ordered)
|
|
55
|
+
|
|
56
|
+
n_ep = len({(f.method, f.path) for f in findings})
|
|
57
|
+
lines = [
|
|
58
|
+
f"결함 {len(findings)}건 / 패턴 {len(patterns)}개 / 엔드포인트 {n_ep}개 "
|
|
59
|
+
"(severity: high 먼저):\n"
|
|
60
|
+
]
|
|
61
|
+
for check, group in ordered:
|
|
62
|
+
level = group[0].level
|
|
63
|
+
endpoints = sorted({f"{f.method} {f.path}" for f in group})
|
|
64
|
+
lines.append(f"[{level.upper()}] {check} — {len(endpoints)}개 엔드포인트")
|
|
65
|
+
hint = _FIX_HINT.get(check)
|
|
66
|
+
if hint:
|
|
67
|
+
lines.append(f" 일괄 수정 방향: {hint}")
|
|
68
|
+
lines.append(" 영향 엔드포인트:")
|
|
69
|
+
for ep in endpoints:
|
|
70
|
+
lines.append(f" - {ep}")
|
|
71
|
+
rep = group[0]
|
|
72
|
+
trig = rep.trigger if len(rep.trigger) <= 160 else rep.trigger[:160] + "…"
|
|
73
|
+
lines.append(f" 대표 예: {trig} → {rep.response_summary}")
|
|
74
|
+
lines.append("")
|
|
75
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# S3.5: scan 출력에 한 줄 고지. 추후 방향 전환(자동패치 유료화 등) 시 "베타였고 공지함" 근거.
|
|
79
|
+
_BETA_NOTICE = (
|
|
80
|
+
"베타: AI 수정 프롬프트를 무료 제공합니다. 정식 출시 시 기능·정책이 변경될 수 있습니다."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _format_prompts(findings: list[Finding]) -> str:
|
|
85
|
+
"""패턴(체크)별로 외부 AI에 그대로 붙여넣을 프롬프트를 텍스트로 조립한다.
|
|
86
|
+
|
|
87
|
+
**LLM을 부르지 않는다** — 이미 정제된 Finding을 텍스트로 조립만 한다(비용 0·결정론).
|
|
88
|
+
패턴 1개 = 프롬프트 1개(_group_by_pattern과 동일 단위). 야생 앱의 "같은 패턴 × N
|
|
89
|
+
엔드포인트"가 자연스럽게 프롬프트 1개로 묶인다.
|
|
90
|
+
|
|
91
|
+
제약(★): 유저 소스를 모르므로 코드를 상상하지 않고(〈유저의 핸들러〉 placeholder)
|
|
92
|
+
관측된 진단정보(요청 트리거·응답 요약)만 담는다. 특정 AI를 지칭하지 않는다(모델 중립).
|
|
93
|
+
"""
|
|
94
|
+
if not findings:
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
bar = "=" * 64
|
|
98
|
+
sep = "─" * 64
|
|
99
|
+
ordered = _group_by_pattern(findings)
|
|
100
|
+
out = [
|
|
101
|
+
"",
|
|
102
|
+
bar,
|
|
103
|
+
"AI 수정 프롬프트 — 키 없이 무료 · 외부 AI에 붙여넣기 (S3.5 · 베타)",
|
|
104
|
+
_BETA_NOTICE,
|
|
105
|
+
bar,
|
|
106
|
+
"각 패턴마다 아래 프롬프트 블록을 통째로 복사해, 쓰는 AI(아무거나)에 붙여넣으세요.",
|
|
107
|
+
"",
|
|
108
|
+
]
|
|
109
|
+
for i, (check, group) in enumerate(ordered, start=1):
|
|
110
|
+
level = group[0].level
|
|
111
|
+
endpoints = sorted({f"{f.method} {f.path}" for f in group})
|
|
112
|
+
problem = _PROBLEM.get(check, f"아래 엔드포인트들에서 '{check}' 위반이 관측되었습니다.")
|
|
113
|
+
|
|
114
|
+
out.append(sep)
|
|
115
|
+
out.append(f"[프롬프트 {i}/{len(ordered)}] [{level.upper()}] {check} · {len(endpoints)}개 엔드포인트")
|
|
116
|
+
out.append(sep)
|
|
117
|
+
# ④ 지시(모델 중립) + 소스 상상 금지 안내
|
|
118
|
+
out.append(
|
|
119
|
+
"당신은 FastAPI 전문가입니다. 아래 진단 정보만 근거로 일괄 수정 패치를 만들어 주세요. "
|
|
120
|
+
"핸들러 소스는 제공되지 않습니다(각 핸들러는 〈유저의 핸들러〉). "
|
|
121
|
+
"코드를 상상하지 말고, 관측된 동작만으로 원인을 추정해 수정하세요."
|
|
122
|
+
)
|
|
123
|
+
out.append("")
|
|
124
|
+
# ① 문제(평문)
|
|
125
|
+
out.append(f"[문제] {problem}")
|
|
126
|
+
out.append("")
|
|
127
|
+
# ② 영향 엔드포인트 목록
|
|
128
|
+
out.append("[영향 엔드포인트]")
|
|
129
|
+
for ep in endpoints:
|
|
130
|
+
out.append(f" - {ep}")
|
|
131
|
+
out.append("")
|
|
132
|
+
# ③ 관측된 동작(엔드포인트별 요청 트리거 → 응답 요약) — 진단정보만
|
|
133
|
+
out.append("[관측된 동작] (스캐너가 실제로 보낸 요청 → 받은 응답)")
|
|
134
|
+
for f in group:
|
|
135
|
+
trig = f.trigger if len(f.trigger) <= 200 else f.trigger[:200] + "…"
|
|
136
|
+
out.append(f" - 요청: {trig}")
|
|
137
|
+
out.append(f" 응답: {f.response_summary}")
|
|
138
|
+
out.append("")
|
|
139
|
+
# ④ 산출물 지시
|
|
140
|
+
out.append("[해줄 것]")
|
|
141
|
+
out.append(" 1. 각 엔드포인트에서 이 동작이 나온 원인을 추정하고,")
|
|
142
|
+
out.append(" 2. FastAPI 관용구에 맞는 일괄 수정 패치(수정 코드 또는 diff)를 제시하고,")
|
|
143
|
+
out.append(" 3. 각 수정이 왜 문제를 해결하는지 설명해 주세요.")
|
|
144
|
+
out.append("")
|
|
145
|
+
return "\n".join(out).rstrip() + "\n"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
149
|
+
parser = argparse.ArgumentParser(
|
|
150
|
+
prog="preship",
|
|
151
|
+
description="FastAPI 스테이징 URL을 schemathesis로 스캔해 결함을 출력한다.",
|
|
152
|
+
)
|
|
153
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
154
|
+
scan_p = sub.add_parser("scan", help="FastAPI URL을 스캔한다.")
|
|
155
|
+
scan_p.add_argument("url", help="FastAPI 베이스 URL (여기서 /openapi.json 수집).")
|
|
156
|
+
scan_p.add_argument(
|
|
157
|
+
"--header",
|
|
158
|
+
action="append",
|
|
159
|
+
default=[],
|
|
160
|
+
metavar='"Name: Value"',
|
|
161
|
+
help='모든 요청에 실을 헤더(반복 가능). 예: --header "Authorization: Bearer ..."',
|
|
162
|
+
)
|
|
163
|
+
return parser
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: list[str] | None = None) -> int:
|
|
167
|
+
args = _build_parser().parse_args(argv)
|
|
168
|
+
if args.command == "scan":
|
|
169
|
+
# 소유권 게이트: 미검증이면 scan·openapi·LLM 등 어떤 외부 요청도 내보내지 않는다.
|
|
170
|
+
verification = WellKnownVerifier().verify(args.url, headers=args.header)
|
|
171
|
+
if not verification.verified:
|
|
172
|
+
sys.stdout.write(_format_rejection(args.url, verification))
|
|
173
|
+
return 3
|
|
174
|
+
try:
|
|
175
|
+
findings = scan(args.url, headers=args.header)
|
|
176
|
+
except ScanError as exc:
|
|
177
|
+
print(f"scan 실패: {exc}", file=sys.stderr)
|
|
178
|
+
return 2
|
|
179
|
+
sys.stdout.write(_format(findings))
|
|
180
|
+
# S3.5: 결함 리포트 그다음에 패턴별 AI 수정 프롬프트(텍스트 조립만, LLM 호출 0).
|
|
181
|
+
sys.stdout.write(_format_prompts(findings))
|
|
182
|
+
_maybe_patch(findings, args)
|
|
183
|
+
return 1 if findings else 0
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _maybe_patch(findings: list[Finding], args) -> None:
|
|
188
|
+
"""결함이 있고 키가 있으면 패치를 생성·출력. 키 없으면 안내만(scan은 이미 출력됨)."""
|
|
189
|
+
if not findings:
|
|
190
|
+
return
|
|
191
|
+
if not has_api_key():
|
|
192
|
+
sys.stdout.write(
|
|
193
|
+
"\nℹ️ ANTHROPIC_API_KEY를 설정하면 결함별 설명·원인·FastAPI 패치를 생성합니다 "
|
|
194
|
+
"(BYOK). 스캔은 키 없이도 동작합니다.\n"
|
|
195
|
+
)
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
diagnoses = generate_patches(findings, args.url, headers=args.header)
|
|
199
|
+
except Exception as exc: # noqa: BLE001 — 스캔 결과는 이미 출력됨; 패치 단계만 graceful 실패
|
|
200
|
+
print(f"\n패치 생성 단계 실패(스캔 결과는 위와 같음): {type(exc).__name__}", file=sys.stderr)
|
|
201
|
+
return
|
|
202
|
+
sys.stdout.write(_format_patches(diagnoses))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_patches(diagnoses: list[Diagnosis]) -> str:
|
|
206
|
+
bar = "=" * 64
|
|
207
|
+
lines = ["", bar, "AI 패치 제안 — BYOK · 소스 없이 진단정보로 생성 (S3)", bar, ""]
|
|
208
|
+
for d in diagnoses:
|
|
209
|
+
lines.append(f"■ {d.method} {d.path}")
|
|
210
|
+
if d.error:
|
|
211
|
+
lines.append(f" ⚠ {d.error}")
|
|
212
|
+
else:
|
|
213
|
+
lines.append(" [설명]")
|
|
214
|
+
lines.append(_indent(d.explanation))
|
|
215
|
+
lines.append(" [원인]")
|
|
216
|
+
lines.append(_indent(d.cause))
|
|
217
|
+
lines.append(" [적용 단계]")
|
|
218
|
+
lines.append(_indent(d.steps))
|
|
219
|
+
if (d.optional or "").strip() and d.optional.strip() != "없음":
|
|
220
|
+
lines.append(" [선택]")
|
|
221
|
+
lines.append(_indent(d.optional))
|
|
222
|
+
lines.append("")
|
|
223
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _indent(text: str, prefix: str = " ") -> str:
|
|
227
|
+
body = (text or "").strip() or "(없음)"
|
|
228
|
+
return "\n".join(prefix + line for line in body.splitlines())
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _format_rejection(url: str, result: VerificationResult) -> str:
|
|
232
|
+
"""소유권 미검증 시 거부 안내 — 이유·방법·토큰을 명확히(버그 오해 방지)."""
|
|
233
|
+
lines = [
|
|
234
|
+
f"✗ 소유권 미검증 — 스캔을 거부합니다 (사유: {result.reason}).",
|
|
235
|
+
"",
|
|
236
|
+
"이 URL이 당신 소유임을 증명해야 스캔합니다 (제3자 서버 스캔 방지).",
|
|
237
|
+
f"대상 서버의 {WELL_KNOWN_PATH} 가 아래 토큰을 반환하게 한 뒤 다시 실행하세요.",
|
|
238
|
+
"",
|
|
239
|
+
f" 토큰: {result.token}",
|
|
240
|
+
"",
|
|
241
|
+
" # FastAPI 예 (라우트 한 줄):",
|
|
242
|
+
" from fastapi.responses import PlainTextResponse",
|
|
243
|
+
f' @app.get("{WELL_KNOWN_PATH}")',
|
|
244
|
+
" def _preflight_verify():",
|
|
245
|
+
f' return PlainTextResponse("{result.token}")',
|
|
246
|
+
"",
|
|
247
|
+
f" 증명 후: preship scan {url}",
|
|
248
|
+
"",
|
|
249
|
+
]
|
|
250
|
+
return "\n".join(lines)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""스캔 엔진: URL -> /openapi.json 수집 -> schemathesis 실행 -> list[Finding] 반환.
|
|
2
|
+
|
|
3
|
+
이 모듈은 CLI/출력 포맷을 모른다. 구조화된 Finding 객체만 반환한다(cli.py가 포맷).
|
|
4
|
+
|
|
5
|
+
통합 방식: S1에서 확정·검증된 스코프 커맨드
|
|
6
|
+
schemathesis run <URL>/openapi.json --phases examples,fuzzing --mode positive --seed 0
|
|
7
|
+
를 동일 venv의 schemathesis 콘솔 스크립트로 **그대로** 서브프로세스 실행한다(행동 패리티).
|
|
8
|
+
결과는 NDJSON 리포트(--report ndjson)로 받아 ScenarioFinished 이벤트를 파싱한다.
|
|
9
|
+
|
|
10
|
+
한계 (fresh state): 스캐너는 HTTP로 대상을 호출하므로, 대상이 요청에 따라 자기 상태를
|
|
11
|
+
변경(예: 인메모리 store에 쓰는 POST 핸들러)하는 것을 막을 수 없다. 재현 가능한 결과를
|
|
12
|
+
위해 **매 스캔 깨끗한/일회용 스테이징 인스턴스**를 전제하라. 상태를 누적하는 대상을
|
|
13
|
+
반복 스캔하면 결과가 흔들릴 수 있다.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import urllib.request
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from ohs_preflight.verify import WELL_KNOWN_PATH
|
|
28
|
+
|
|
29
|
+
# S1 §부록 B 잠금 커맨드와 정확히 일치 (--checks 미지정 → 4.x 기본=all).
|
|
30
|
+
SCOPE_FLAGS = ["--phases", "examples,fuzzing", "--mode", "positive", "--seed", "0"]
|
|
31
|
+
|
|
32
|
+
# A(신호 정제): 노이즈 체크는 수집 후 리포트에서 제외(탐지 불변 — 필터 레이어만).
|
|
33
|
+
# positive_data_acceptance = 앱이 잘못된 입력을 옳게 거부했는데 "정상 입력 거부"로 잡는 false positive.
|
|
34
|
+
EXCLUDED_CHECKS = frozenset({"positive_data_acceptance"})
|
|
35
|
+
|
|
36
|
+
# A(severity): 500/크래시는 high, 계약 미문서·스키마 위반은 low.
|
|
37
|
+
_HIGH_CHECKS = frozenset({"not_a_server_error"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _level(check: str) -> str:
|
|
41
|
+
return "high" if check in _HIGH_CHECKS else "low"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Finding:
|
|
46
|
+
"""결함 1건 = (엔드포인트, 뜨는 체크) 단위. S1_spec §5 매트릭스가 분류 근거."""
|
|
47
|
+
|
|
48
|
+
method: str
|
|
49
|
+
path: str
|
|
50
|
+
check: str
|
|
51
|
+
trigger: str # 결함을 발사한 입력(요청)
|
|
52
|
+
response_summary: str # 응답 status + content-type + 본문 요약
|
|
53
|
+
severity: str = "" # schemathesis failure 유형(부가 정보)
|
|
54
|
+
level: str = "low" # A: high(500/크래시) | low(계약 미문서·스키마 위반)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ScanError(RuntimeError):
|
|
58
|
+
"""스캔 자체가 실행되지 못한 경우(대상 도달 불가, schemathesis 부재 등)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def scan(url: str, headers: list[str] | None = None) -> list[Finding]:
|
|
62
|
+
"""`url`(FastAPI 베이스 URL)을 스캔해 결함 list[Finding]를 반환한다.
|
|
63
|
+
|
|
64
|
+
`headers`: "Name: Value" 문자열 목록. 모든 요청에 실린다(--header 전달).
|
|
65
|
+
"""
|
|
66
|
+
exe = _find_schemathesis()
|
|
67
|
+
if exe is None:
|
|
68
|
+
raise ScanError("schemathesis 실행 파일을 찾지 못함(동일 venv에 설치 필요).")
|
|
69
|
+
|
|
70
|
+
with tempfile.TemporaryDirectory(prefix="ohs-preflight-") as tmp:
|
|
71
|
+
ndjson_path = Path(tmp) / "events.ndjson"
|
|
72
|
+
cmd = [
|
|
73
|
+
exe, "run", _schema_url(url), *SCOPE_FLAGS,
|
|
74
|
+
"--report", "ndjson", "--report-dir", tmp,
|
|
75
|
+
"--report-ndjson-path", str(ndjson_path),
|
|
76
|
+
]
|
|
77
|
+
for h in headers or []:
|
|
78
|
+
cmd += ["--header", h]
|
|
79
|
+
|
|
80
|
+
# cwd=tmp: schemathesis가 cwd에 만드는 .schemathesis/ 캐시를 임시디렉터리에 가둔다(M11,
|
|
81
|
+
# 자동 정리). URL·리포트 경로가 모두 절대경로라 동작은 불변.
|
|
82
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, cwd=tmp)
|
|
83
|
+
# schemathesis는 결함을 찾으면 비0으로 종료한다(정상). 리포트 부재만 오류로 본다.
|
|
84
|
+
if not ndjson_path.exists():
|
|
85
|
+
raise ScanError(
|
|
86
|
+
"schemathesis가 리포트를 생성하지 못함.\n"
|
|
87
|
+
f"exit={proc.returncode}\nstderr:\n{(proc.stderr or '')[-2000:]}"
|
|
88
|
+
)
|
|
89
|
+
findings = _parse(ndjson_path)
|
|
90
|
+
# M10: 검증용 well-known 경로 제외(자기참조 방지). A(a): positive_data_acceptance 등
|
|
91
|
+
# false-positive 체크 제외. 둘 다 '필터 레이어' — 탐지 자체는 불변.
|
|
92
|
+
return [
|
|
93
|
+
f for f in findings
|
|
94
|
+
if f.path != WELL_KNOWN_PATH and f.check not in EXCLUDED_CHECKS
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _find_schemathesis() -> str | None:
|
|
99
|
+
"""동일 venv(현재 인터프리터)의 schemathesis 콘솔 스크립트를 PATH 비의존으로 해석.
|
|
100
|
+
|
|
101
|
+
`ohs-preflight`를 풀패스로 호출하면 PATH에 venv/bin이 없을 수 있으므로,
|
|
102
|
+
sys.executable의 형제 경로를 우선 본 뒤 PATH(shutil.which)로 폴백한다.
|
|
103
|
+
"""
|
|
104
|
+
sibling = Path(sys.executable).parent / "schemathesis"
|
|
105
|
+
if sibling.exists():
|
|
106
|
+
return str(sibling)
|
|
107
|
+
return shutil.which("schemathesis")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _schema_url(url: str) -> str:
|
|
111
|
+
u = url.rstrip("/")
|
|
112
|
+
return u if u.endswith("/openapi.json") else u + "/openapi.json"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_headers(headers: list[str] | None) -> list[tuple[str, str]]:
|
|
116
|
+
""""Name: Value" 문자열 목록을 (name, value) 튜플로 파싱."""
|
|
117
|
+
out: list[tuple[str, str]] = []
|
|
118
|
+
for h in headers or []:
|
|
119
|
+
name, sep, value = h.partition(":")
|
|
120
|
+
if sep:
|
|
121
|
+
out.append((name.strip(), value.strip()))
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def fetch_openapi(url: str, headers: list[str] | None = None) -> dict:
|
|
126
|
+
"""대상의 /openapi.json을 가져와 dict로 반환(공개 계약 — S3 패치 진단용).
|
|
127
|
+
|
|
128
|
+
헤더는 scan과 동일하게 모든 요청에 적용된다(--header).
|
|
129
|
+
"""
|
|
130
|
+
request = urllib.request.Request(_schema_url(url))
|
|
131
|
+
for name, value in _parse_headers(headers):
|
|
132
|
+
request.add_header(name, value)
|
|
133
|
+
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 (사용자 지정 URL)
|
|
134
|
+
return json.loads(response.read().decode("utf-8"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse(ndjson_path: Path) -> list[Finding]:
|
|
138
|
+
"""NDJSON의 ScenarioFinished 이벤트에서 실패 체크를 추출, (method,path,check)로 dedup."""
|
|
139
|
+
seen: set[tuple[str, str, str]] = set()
|
|
140
|
+
findings: list[Finding] = []
|
|
141
|
+
|
|
142
|
+
for line in ndjson_path.read_text().splitlines():
|
|
143
|
+
line = line.strip()
|
|
144
|
+
if not line:
|
|
145
|
+
continue
|
|
146
|
+
event = json.loads(line)
|
|
147
|
+
scenario = event.get("ScenarioFinished")
|
|
148
|
+
if not scenario:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
recorder = scenario.get("recorder", {})
|
|
152
|
+
cases = recorder.get("cases", {})
|
|
153
|
+
checks = recorder.get("checks", {})
|
|
154
|
+
interactions = recorder.get("interactions", {})
|
|
155
|
+
|
|
156
|
+
for case_id, check_list in checks.items():
|
|
157
|
+
failed = [c for c in check_list if c.get("status") == "failure"]
|
|
158
|
+
if not failed:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
case = cases.get(case_id, {}).get("value", {})
|
|
162
|
+
method = case.get("method", "?")
|
|
163
|
+
path = case.get("path", "?")
|
|
164
|
+
interaction = interactions.get(case_id, {})
|
|
165
|
+
trigger = _trigger(method, path, case, interaction.get("request", {}))
|
|
166
|
+
response_summary = _response_summary(interaction.get("response", {}))
|
|
167
|
+
|
|
168
|
+
for c in failed:
|
|
169
|
+
name = c.get("name", "?")
|
|
170
|
+
key = (method, path, name)
|
|
171
|
+
if key in seen:
|
|
172
|
+
continue
|
|
173
|
+
seen.add(key)
|
|
174
|
+
failure = (c.get("failure_info") or {}).get("failure", {})
|
|
175
|
+
findings.append(Finding(
|
|
176
|
+
method=method,
|
|
177
|
+
path=path,
|
|
178
|
+
check=name,
|
|
179
|
+
trigger=trigger,
|
|
180
|
+
response_summary=response_summary,
|
|
181
|
+
severity=failure.get("type", ""),
|
|
182
|
+
level=_level(name),
|
|
183
|
+
))
|
|
184
|
+
|
|
185
|
+
findings.sort(key=lambda f: (f.path, f.method, f.check))
|
|
186
|
+
return findings
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _decode(blob) -> str:
|
|
190
|
+
"""NDJSON의 본문 표현({"$base64": ...} 또는 일반 값)을 문자열로."""
|
|
191
|
+
if isinstance(blob, dict) and "$base64" in blob:
|
|
192
|
+
try:
|
|
193
|
+
return base64.b64decode(blob["$base64"]).decode("utf-8", "replace")
|
|
194
|
+
except Exception:
|
|
195
|
+
return str(blob["$base64"])
|
|
196
|
+
if blob is None:
|
|
197
|
+
return ""
|
|
198
|
+
return blob if isinstance(blob, str) else json.dumps(blob, ensure_ascii=False)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _trigger(method: str, path: str, case: dict, request: dict) -> str:
|
|
202
|
+
uri = request.get("uri") or path # uri는 쿼리스트링까지 포함(GET 트리거 식별에 유용)
|
|
203
|
+
body = case.get("body")
|
|
204
|
+
body_str = json.dumps(body, ensure_ascii=False) if body is not None else _decode(request.get("body"))
|
|
205
|
+
out = f"{method} {uri}"
|
|
206
|
+
if body_str:
|
|
207
|
+
out += f" body={body_str}"
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _response_summary(response: dict) -> str:
|
|
212
|
+
if not response:
|
|
213
|
+
return "(응답 미수집)"
|
|
214
|
+
status = response.get("status_code", "?")
|
|
215
|
+
content_type = ""
|
|
216
|
+
for key, value in (response.get("headers") or {}).items():
|
|
217
|
+
if key.lower() == "content-type":
|
|
218
|
+
content_type = value[0] if isinstance(value, list) else value
|
|
219
|
+
break
|
|
220
|
+
body = _decode(response.get("content")).replace("\n", " ")[:120]
|
|
221
|
+
return f"{status} {content_type}; {body}".strip()
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""AI 패치 통역 (S3) — core 하위 모듈.
|
|
2
|
+
|
|
3
|
+
(가) 방식: **소스 코드 없이** 진단 정보(요청 / 관측 응답 / 선언된 공개 OpenAPI 계약)만
|
|
4
|
+
LLM에 보내 결함당 "설명 + 원인 + FastAPI 패치"를 생성한다. cli는 결과를 포맷만 한다.
|
|
5
|
+
|
|
6
|
+
BYOK: 키는 `ANTHROPIC_API_KEY` env에서만 읽는다(anthropic SDK가 자동 로드). 키는 코드·로그·
|
|
7
|
+
에러에 출력하지 않는다. 키가 없으면 cli가 패치 단계를 건너뛰고 scan은 그대로 동작한다.
|
|
8
|
+
|
|
9
|
+
페이로드에서 제외(유저 미지의 내부 정보): 소스 코드, schemathesis 체크 이름, `[det]` example 스캐폴딩.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from ohs_preflight.core import Finding, fetch_openapi
|
|
19
|
+
|
|
20
|
+
DEFAULT_MODEL = "claude-opus-4-8" # env ANTHROPIC_MODEL로 override 가능
|
|
21
|
+
|
|
22
|
+
_SYSTEM = (
|
|
23
|
+
"당신은 시니어 FastAPI 엔지니어다. 자동 API 프로브가 수집한 진단정보(요청, 관측된 응답, "
|
|
24
|
+
"선언된 OpenAPI 계약)만 받는다 — 소스 코드는 절대 받지 않는다. 너의 산출물은 유저의 기존 "
|
|
25
|
+
"핸들러에 적용할 '적용 가이드'이지, 파일을 새로 쓴 코드가 아니다.\n"
|
|
26
|
+
"규칙:\n"
|
|
27
|
+
"- 파일/앱을 통째로 재작성하지 마라. 유저 코드를 지어내지 마라 — 진단정보에 없는 변수명·필드·"
|
|
28
|
+
"핸들러 본문·import·저장소·비즈니스 로직을 상상 금지. 모르는 부분은 〈유저의 핸들러〉·〈유저의 "
|
|
29
|
+
"모델〉처럼 placeholder로 지칭하고 그대로 둔다.\n"
|
|
30
|
+
"- 단, 수정에 쓰는 **구체적 FastAPI 심볼은 정확히 명시**하라: 예) Path(..., ge=1), "
|
|
31
|
+
"Query(ge=1, le=100), Field(gt=0), HTTPException(status_code=404, ...), response_model=..., "
|
|
32
|
+
"모델 인스턴스 반환. '검증을 추가하라' 같은 추상적 지시로 끝내지 마라.\n"
|
|
33
|
+
"- **상태코드 문서화 필수**: 핸들러가 새 상태코드를 raise/return하면(404·409·400 등) 반드시 같은 "
|
|
34
|
+
"라우트 데코레이터에 responses={코드: {\"description\": ...}}로 문서화하는 단계를 포함하라. "
|
|
35
|
+
"누락하면 스펙 미준수(conformance)로 재검 실패한다.\n"
|
|
36
|
+
"- **최소 침습**: 결함 수정에 꼭 필요한 변경만. 결함과 무관한 추가(새 검증 필드, EmailStr, 로깅, "
|
|
37
|
+
"불필요한 response_model 신설)는 넣지 마라. 결함 수정에 불필수인 강화는 '## 선택'에만 두고 선택임을 밝혀라.\n"
|
|
38
|
+
"- 500을 try/except로 가리는 임시방편이 아니라 타입 검증 위임·Pydantic 모델·HTTPException(문서화된 "
|
|
39
|
+
"상태코드)·response_model 정합 같은 프레임워크 표준 idiom을 우선하라.\n"
|
|
40
|
+
"- 산문은 한국어, 코드 심볼/스니펫은 실행 가능한 형태로. 반드시 '## 설명', '## 원인', '## 적용 단계', "
|
|
41
|
+
"'## 선택' 네 섹션만 출력하라('## 선택'에 내용이 없으면 \"없음\")."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_TASK = (
|
|
45
|
+
"\n\n[작업]\n소스 코드 없이 — 위 요청, 관측된 응답, 선언된 계약만으로 — 판단하라. "
|
|
46
|
+
"정확히 아래 네 섹션으로 답하라:\n"
|
|
47
|
+
"## 설명\n(결함을 평문으로 2~4문장)\n"
|
|
48
|
+
"## 원인\n(추정 근본 원인)\n"
|
|
49
|
+
"## 적용 단계\n(유저의 기존 핸들러/모델에 적용할 변경을 번호 매긴 줄 단위 지시로. 각 단계에 쓰는 "
|
|
50
|
+
"구체적 FastAPI 심볼을 명시. 새 상태코드를 쓰면 responses={...} 문서화 단계를 반드시 포함. "
|
|
51
|
+
"전체 파일 재작성 금지 — 바뀌는 시그니처/한두 줄 스니펫만.)\n"
|
|
52
|
+
"## 선택\n(결함 수정에 불필수인 강화만, 각 항목 앞에 \"선택:\". 없으면 \"없음\".)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Diagnosis:
|
|
58
|
+
"""결함 1건(엔드포인트 단위)에 대한 LLM 산출물. cli가 포맷만 한다."""
|
|
59
|
+
|
|
60
|
+
method: str
|
|
61
|
+
path: str
|
|
62
|
+
explanation: str = ""
|
|
63
|
+
cause: str = ""
|
|
64
|
+
steps: str = "" # 적용 단계 (유저 핸들러에 적용할 줄 단위 지시)
|
|
65
|
+
optional: str = "" # 선택 (불필수 강화)
|
|
66
|
+
error: str = "" # 이 엔드포인트의 패치 생성이 실패한 경우 사유(키 누수 없음)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def has_api_key() -> bool:
|
|
70
|
+
"""ANTHROPIC_API_KEY가 환경에 있는지만 확인(값은 다루지 않음)."""
|
|
71
|
+
return bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def model_name() -> str:
|
|
75
|
+
return os.environ.get("ANTHROPIC_MODEL", DEFAULT_MODEL)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def generate_patches(findings: list[Finding], url: str, headers: list[str] | None = None) -> list[Diagnosis]:
|
|
79
|
+
"""결함을 엔드포인트로 묶어 엔드포인트별 패치 1건을 생성한다(6 엔드포인트 → 6 패치).
|
|
80
|
+
|
|
81
|
+
호출 전 cli가 has_api_key()로 키 존재를 보장한다.
|
|
82
|
+
"""
|
|
83
|
+
import anthropic # lazy: scan 경로가 anthropic import에 의존하지 않게
|
|
84
|
+
|
|
85
|
+
groups: "OrderedDict[tuple[str, str], list[Finding]]" = OrderedDict()
|
|
86
|
+
for f in findings:
|
|
87
|
+
groups.setdefault((f.method, f.path), []).append(f)
|
|
88
|
+
|
|
89
|
+
# 공개 계약(소스 아님). 못 가져오면 계약 없이 진행.
|
|
90
|
+
try:
|
|
91
|
+
openapi = fetch_openapi(url, headers)
|
|
92
|
+
except Exception:
|
|
93
|
+
openapi = None
|
|
94
|
+
|
|
95
|
+
client = anthropic.Anthropic() # ANTHROPIC_API_KEY 자동 로드
|
|
96
|
+
results: list[Diagnosis] = []
|
|
97
|
+
for (method, path), group in groups.items():
|
|
98
|
+
try:
|
|
99
|
+
payload = _build_payload(method, path, group, openapi)
|
|
100
|
+
text = _call_llm(client, payload)
|
|
101
|
+
explanation, cause, steps, optional = _parse_sections(text)
|
|
102
|
+
results.append(Diagnosis(method, path, explanation, cause, steps, optional))
|
|
103
|
+
except anthropic.AuthenticationError:
|
|
104
|
+
results.append(Diagnosis(method, path, error="API 키 인증 실패 — ANTHROPIC_API_KEY 확인"))
|
|
105
|
+
except anthropic.RateLimitError:
|
|
106
|
+
results.append(Diagnosis(method, path, error="레이트리밋 — 잠시 후 재시도"))
|
|
107
|
+
except Exception as exc: # noqa: BLE001 — 한 엔드포인트 실패가 전체를 죽이지 않게
|
|
108
|
+
results.append(Diagnosis(method, path, error=f"패치 생성 실패: {type(exc).__name__}"))
|
|
109
|
+
return results
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _call_llm(client, payload: str) -> str:
|
|
113
|
+
message = client.messages.create(
|
|
114
|
+
model=model_name(),
|
|
115
|
+
max_tokens=4096,
|
|
116
|
+
thinking={"type": "adaptive"}, # 진단→패치는 추론 작업
|
|
117
|
+
system=[{"type": "text", "text": _SYSTEM, "cache_control": {"type": "ephemeral"}}],
|
|
118
|
+
messages=[{"role": "user", "content": payload}],
|
|
119
|
+
)
|
|
120
|
+
return "".join(b.text for b in message.content if getattr(b, "type", None) == "text")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _build_payload(method: str, path: str, findings: list[Finding], openapi: dict | None) -> str:
|
|
124
|
+
lines = [
|
|
125
|
+
"자동 API 프로브가 FastAPI 엔드포인트를 스캔해 결함을 발견했다. 소스 코드는 없다.",
|
|
126
|
+
f"\nENDPOINT: {method} {path}",
|
|
127
|
+
"\n[프로브가 보낸 요청(들) — 결함을 유발한 입력]",
|
|
128
|
+
]
|
|
129
|
+
for trigger in _unique(f.trigger for f in findings):
|
|
130
|
+
lines.append(f" - {trigger}")
|
|
131
|
+
lines.append("\n[관측된 응답]")
|
|
132
|
+
for summary in _unique(f.response_summary for f in findings):
|
|
133
|
+
lines.append(f" - {summary}")
|
|
134
|
+
|
|
135
|
+
contract = _operation_contract(openapi, method, path)
|
|
136
|
+
if contract is not None:
|
|
137
|
+
import json
|
|
138
|
+
lines.append("\n[엔드포인트의 선언된 OpenAPI 계약 (공개·구조만; example 제외)]")
|
|
139
|
+
lines.append(json.dumps(contract, ensure_ascii=False, indent=2)[:4000])
|
|
140
|
+
|
|
141
|
+
lines.append(_TASK)
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _operation_contract(openapi: dict | None, method: str, path: str) -> dict | None:
|
|
146
|
+
"""해당 operation의 선언 계약(요청/응답 스키마)을 추출. example/examples는 제거."""
|
|
147
|
+
if not openapi:
|
|
148
|
+
return None
|
|
149
|
+
operation = (openapi.get("paths", {}).get(path) or {}).get(method.lower())
|
|
150
|
+
if not operation:
|
|
151
|
+
return None
|
|
152
|
+
schemas = openapi.get("components", {}).get("schemas", {})
|
|
153
|
+
|
|
154
|
+
referenced: dict = {}
|
|
155
|
+
pending = _collect_refs(operation)
|
|
156
|
+
while pending:
|
|
157
|
+
name = pending.pop()
|
|
158
|
+
if name in referenced or name not in schemas:
|
|
159
|
+
continue
|
|
160
|
+
referenced[name] = schemas[name]
|
|
161
|
+
pending |= _collect_refs(schemas[name])
|
|
162
|
+
|
|
163
|
+
return _strip_examples({"operation": operation, "referenced_schemas": referenced})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _collect_refs(node) -> set[str]:
|
|
167
|
+
refs: set[str] = set()
|
|
168
|
+
if isinstance(node, dict):
|
|
169
|
+
ref = node.get("$ref")
|
|
170
|
+
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
|
|
171
|
+
refs.add(ref.rsplit("/", 1)[-1])
|
|
172
|
+
for value in node.values():
|
|
173
|
+
refs |= _collect_refs(value)
|
|
174
|
+
elif isinstance(node, list):
|
|
175
|
+
for value in node:
|
|
176
|
+
refs |= _collect_refs(value)
|
|
177
|
+
return refs
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _strip_examples(obj):
|
|
181
|
+
if isinstance(obj, dict):
|
|
182
|
+
return {k: _strip_examples(v) for k, v in obj.items() if k not in ("example", "examples")}
|
|
183
|
+
if isinstance(obj, list):
|
|
184
|
+
return [_strip_examples(v) for v in obj]
|
|
185
|
+
return obj
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_SECTION_RE = re.compile(r"^#{1,6}\s*(설명|원인|적용\s*단계|선택)\s*$", re.MULTILINE)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_sections(text: str) -> tuple[str, str, str, str]:
|
|
192
|
+
matches = list(_SECTION_RE.finditer(text))
|
|
193
|
+
if not matches:
|
|
194
|
+
return "", "", text.strip(), "" # 헤더 못 찾으면 원문 보존(적용 단계 칸)
|
|
195
|
+
out = {"설명": "", "원인": "", "적용단계": "", "선택": ""}
|
|
196
|
+
for i, m in enumerate(matches):
|
|
197
|
+
key = m.group(1).replace(" ", "") # "적용 단계" → "적용단계"
|
|
198
|
+
start = m.end()
|
|
199
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
200
|
+
out[key] = text[start:end].strip()
|
|
201
|
+
return out["설명"], out["원인"], out["적용단계"], out["선택"]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _unique(items):
|
|
205
|
+
seen = []
|
|
206
|
+
for item in items:
|
|
207
|
+
if item not in seen:
|
|
208
|
+
seen.append(item)
|
|
209
|
+
return seen
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""URL 소유권 검증 게이트 (S4a) — core 레벨 모듈.
|
|
2
|
+
|
|
3
|
+
목적: Preship으로 '자기 것 아닌 URL'을 스캔하는 악용 차단. scan 직전 1회, 대상 URL이
|
|
4
|
+
요청자 자산임을 well-known 토큰으로 증명한다. **미검증이면 스캔 외부 요청(openapi 수집·
|
|
5
|
+
schemathesis·LLM)을 절대 내보내지 않는다** — 유일한 외부 요청은 well-known 프로브 GET 1회.
|
|
6
|
+
|
|
7
|
+
검증 로직은 OwnershipVerifier 전략으로 추상화(향후 DNS 등 추가 여지). 현재 구현은
|
|
8
|
+
WellKnownVerifier 하나뿐 — DNS 등은 구현하지 않는다.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
WELL_KNOWN_PATH = "/.well-known/preflight-verify"
|
|
19
|
+
|
|
20
|
+
# 고정 네임스페이스 salt(한 번 무작위로 정해 코드에 박음). 토큰을 URL에 결합하고 무상태로
|
|
21
|
+
# 재현 가능하게 만든다("난수 기반" 충족). 비밀이 아니어도 됨 — 보안은 '대상 서버에 이 토큰을
|
|
22
|
+
# 올릴 수 있는가'에서 나온다(토큰 비밀성 아님).
|
|
23
|
+
_SALT = "b9c41e7a2f8d4063a1e5c0d7f2b3a6e4"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def issue_token(url: str) -> str:
|
|
27
|
+
"""URL에 결정론적으로 묶인 검증 토큰. 발급 run과 검증 run이 같은 값을 얻도록(무상태)."""
|
|
28
|
+
base = url.rstrip("/")
|
|
29
|
+
return hashlib.sha256(f"{_SALT}::{base}".encode("utf-8")).hexdigest()[:32]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class VerificationResult:
|
|
34
|
+
verified: bool
|
|
35
|
+
token: str
|
|
36
|
+
reason: str = "" # 미검증 사유(사람용; 키/민감정보 없음)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OwnershipVerifier(Protocol):
|
|
40
|
+
"""소유권 검증 전략. 구현체는 verify(url, headers) -> VerificationResult를 제공한다."""
|
|
41
|
+
|
|
42
|
+
def verify(self, url: str, headers: list[str] | None = None) -> VerificationResult: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WellKnownVerifier:
|
|
46
|
+
"""`<url>/.well-known/preflight-verify`에 발급 토큰이 게시됐는지로 소유권을 증명한다.
|
|
47
|
+
|
|
48
|
+
well-known 프로브 GET 단 1회만 외부 요청을 낸다. 그 외 어떤 요청도 하지 않는다.
|
|
49
|
+
도달 실패·부재·불일치는 전부 verified=False로 수렴한다(스캔으로 넘어가지 않음).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def verify(self, url: str, headers: list[str] | None = None) -> VerificationResult:
|
|
53
|
+
token = issue_token(url)
|
|
54
|
+
probe_url = url.rstrip("/") + WELL_KNOWN_PATH
|
|
55
|
+
request = urllib.request.Request(probe_url)
|
|
56
|
+
for name, value in _parse_headers(headers):
|
|
57
|
+
request.add_header(name, value)
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310 (사용자 지정 URL)
|
|
60
|
+
body = response.read(4096).decode("utf-8", "replace")
|
|
61
|
+
except urllib.error.HTTPError as exc:
|
|
62
|
+
return VerificationResult(False, token, reason=f"well-known 미발견 (HTTP {exc.code})")
|
|
63
|
+
except Exception as exc: # noqa: BLE001 — 도달 실패 전부 '미검증'으로 (요청 안 나감)
|
|
64
|
+
return VerificationResult(False, token, reason=f"well-known 도달 실패 ({type(exc).__name__})")
|
|
65
|
+
if token in body:
|
|
66
|
+
return VerificationResult(True, token)
|
|
67
|
+
return VerificationResult(False, token, reason="well-known 토큰 불일치")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_headers(headers: list[str] | None) -> list[tuple[str, str]]:
|
|
71
|
+
out: list[tuple[str, str]] = []
|
|
72
|
+
for h in headers or []:
|
|
73
|
+
name, sep, value = h.partition(":")
|
|
74
|
+
if sep:
|
|
75
|
+
out.append((name.strip(), value.strip()))
|
|
76
|
+
return out
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: preship
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Preship: FastAPI 스테이징 URL을 퍼징해 터지는 입력과 패턴별 AI 수정 프롬프트를 내주는 출시 전 진단 CLI.
|
|
5
|
+
Author: Preship
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Keywords: fastapi,testing,fuzzing,openapi,api,preflight
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: schemathesis==4.20.3
|
|
11
|
+
Requires-Dist: anthropic==0.105.2
|
|
12
|
+
|
|
13
|
+
# Preship
|
|
14
|
+
|
|
15
|
+
**FastAPI 스테이징 앱을 출시 전에 자동으로 두드려, 터지는 입력과 고치는 법을 찾아주는 CLI 도구.**
|
|
16
|
+
|
|
17
|
+
스테이징 URL 하나만 주면 모든 엔드포인트에 입력을 퍼징해서:
|
|
18
|
+
|
|
19
|
+
- **무엇이 터지는지** — 500 에러, 문서화 안 된 응답, 스키마 불일치를 패턴별로 묶어서 보여주고
|
|
20
|
+
- **어떻게 고치는지** — 그대로 복사해 *당신이 쓰는 AI*(ChatGPT·Claude·Gemini 등 아무거나)에
|
|
21
|
+
붙여넣을 **수정 프롬프트**를 패턴마다 1개씩 만들어 줍니다.
|
|
22
|
+
|
|
23
|
+
API 키 필요 없습니다. 결함을 찾고 수정 프롬프트를 받는 데까지 전부 키 없이 됩니다.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 1. 설치
|
|
28
|
+
|
|
29
|
+
Python 3.10 이상이 필요합니다.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install preship
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
설치되면 `preship` 명령이 생깁니다.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
preship --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 2. 스테이징 URL 준비 + 소유권 확인 라우트 달기
|
|
42
|
+
|
|
43
|
+
Preship은 **당신이 소유한 URL만** 스캔합니다 (남의 서버를 두드리지 못하게). 그래서 스캔 전에
|
|
44
|
+
"이 URL은 내 것"임을 한 번 증명해야 합니다 — 확인용 라우트 한 개를 앱에 다는 게 전부입니다.
|
|
45
|
+
|
|
46
|
+
먼저 스캔을 한 번 실행하면, Preship이 **당신만의 토큰**과 **그대로 붙여넣을 라우트 코드**를
|
|
47
|
+
출력해 줍니다. 그 토큰을 아래 스니펫에 끼워 앱에 추가하고 재배포하세요.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# 당신의 FastAPI 앱에 이 라우트 하나만 추가 (app = FastAPI() 아래 아무 곳)
|
|
51
|
+
from fastapi.responses import PlainTextResponse
|
|
52
|
+
|
|
53
|
+
@app.get("/.well-known/preflight-verify")
|
|
54
|
+
def preflight_verify():
|
|
55
|
+
return PlainTextResponse("여기에 scan이 알려준 당신의 토큰을 붙여넣으세요")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> 토큰은 첫 스캔 실행이 정확히 알려주고, 라우트 코드도 함께 출력합니다. 추측할 필요 없이
|
|
59
|
+
> 출력된 걸 그대로 복사해 붙여넣으면 됩니다. (토큰은 URL마다 고정이라 한 번만 달면 됩니다.)
|
|
60
|
+
|
|
61
|
+
## 3. 스캔 실행
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
preship scan https://your-staging.example.com
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
스테이징에 인증이 걸려 있으면 헤더를 함께 넘기세요:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
preship scan https://your-staging.example.com --header "Authorization: Bearer <토큰>"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 4. 결과 읽는 법
|
|
74
|
+
|
|
75
|
+
출력은 두 부분입니다.
|
|
76
|
+
|
|
77
|
+
**(1) 결함 리포트** — 같은 종류의 문제를 패턴으로 묶고, `[HIGH]`(서버가 500으로 터짐) /
|
|
78
|
+
`[LOW]`(응답 계약 불일치)로 심각도를 표시합니다. 각 패턴마다 영향받는 엔드포인트 목록과
|
|
79
|
+
실제로 보냈던 요청 → 받은 응답이 따라옵니다.
|
|
80
|
+
|
|
81
|
+
**(2) AI 수정 프롬프트** — 패턴마다 프롬프트 블록이 1개씩 나옵니다. **블록을 통째로 복사해
|
|
82
|
+
당신이 쓰는 AI에 그대로 붙여넣으면**, 원인 추정 + FastAPI 수정 패치 + 설명을 받습니다.
|
|
83
|
+
프롬프트는 당신 소스 코드를 담지 않고(스캐너가 관측한 동작만), 특정 AI에 묶이지 않습니다.
|
|
84
|
+
|
|
85
|
+
> 받은 패치를 적용하고 다시 `preship scan` 하면 그 패턴이 사라졌는지 바로 확인할 수 있습니다.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 고급 (선택): 자동 패치 초안
|
|
90
|
+
|
|
91
|
+
자동 패치 초안까지 원하면, **당신의** Anthropic API 키를 **당신의** 환경변수에 넣고 스캔하세요.
|
|
92
|
+
넣지 않아도 위의 수정 프롬프트는 그대로 나옵니다 — 키는 어디까지나 선택입니다.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export ANTHROPIC_API_KEY="sk-ant-..." # 당신의 키. 베타 기본 경로는 키 없이 프롬프트만으로 충분합니다.
|
|
96
|
+
preship scan https://your-staging.example.com
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 개발 / 문서
|
|
102
|
+
|
|
103
|
+
설계 계약과 진행 기록은 `docs/`에 있습니다 — `docs/S0_scope.md`(범위), `docs/S1_spec.md`(데모
|
|
104
|
+
앱 스펙·채점 기준, 읽기 전용), `docs/HANDOFF_LOG.md`(빌드 로그). `fixtures/`는 테스트용 버그 앱
|
|
105
|
+
(제품 코드 아님).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ohs_preflight/__init__.py
|
|
4
|
+
src/ohs_preflight/cli.py
|
|
5
|
+
src/ohs_preflight/core.py
|
|
6
|
+
src/ohs_preflight/patch.py
|
|
7
|
+
src/ohs_preflight/verify.py
|
|
8
|
+
src/preship.egg-info/PKG-INFO
|
|
9
|
+
src/preship.egg-info/SOURCES.txt
|
|
10
|
+
src/preship.egg-info/dependency_links.txt
|
|
11
|
+
src/preship.egg-info/entry_points.txt
|
|
12
|
+
src/preship.egg-info/requires.txt
|
|
13
|
+
src/preship.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ohs_preflight
|