코딩 표준
이 컨벤션들은 코드 리뷰, CI lint, (가능한 경우) 자동 체크로 강제됩니다. 첫 PR 전에 읽어 두세요 — 나중에 수정하면 사이클을 낭비합니다.
모든 컨트리뷰터. 코드 블록을 건드리는 chore·docs PR을 포함해 모든 PR에 적용됩니다.
TypeScript — strict 모드, any 금지
apps/frontend/tsconfig.json은 "strict": true와 "noImplicitAny": true로 동작합니다. 구체적으로:
any캐스팅 금지.any에 손이 간다면 보통 type guard 또는 generic이 빠진 것입니다.unknown을 사용하고 의도적으로 좁히세요.- non-null assertion(
!) 금지 — 해당 시점에 값이 증명적으로 non-null이고 그 이유를 주석으로 설명하지 않는 한. - enum보다 discriminated union. Enum은 transitive import를 누설합니다. 리터럴 union(
type Status = "pending" | "running" | "succeeded")은 tree-shake됩니다.
좁히기의 정당한 예:
// `data`는 서버에서 JSON으로 오므로 사용 전에 런타임 검증.
function isProject(value: unknown): value is Project {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as { id: unknown }).id === "string"
);
}
any가 정말 불가피한 경우(예: 타입이 없는 third-party 콜백과의 interop), 타입 경계 함수로 감싸고 한 줄 정당화와 함께 // eslint-disable-next-line @typescript-eslint/no-explicit-any를 붙이세요.
Pydantic v2 — BaseModel + Field(...)
백엔드는 Pydantic v2입니다. 스키마는 apps/backend/schemas/에 둡니다.
- 항상 타입 선언. 필수 필드는
Field(...), 기본값은Field(default=...)또는Field(default_factory=...). - 교차 필드 불변식은
model_validator.__init__에서 raise하지 마세요. validator가 적절한 시점에 동작하며 구조화된 오류를 만듭니다. arbitrary_types_allowed회피. 검증을 우회합니다. 대신 third-party 타입을 커스텀 validator로 감싸세요.
from pydantic import BaseModel, Field, model_validator
class ProjectCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
repository_url: str = Field(pattern=r"^(https?|ssh)://")
visibility: Literal["team-only", "org-wide"] = "team-only"
@model_validator(mode="after")
def visibility_requires_team_admin(self) -> "ProjectCreate":
# 교차 필드 체크. 위반 시 ValueError raise.
return self
Alembic — forward-only
마이그레이션 정책은 forward-only입니다. downgrade()는 구현하지 않습니다.
def upgrade() -> None:
op.add_column("projects", sa.Column("watch_list", sa.ARRAY(sa.String())))
op.execute("UPDATE projects SET watch_list = ARRAY[]::text[]")
def downgrade() -> None:
raise NotImplementedError("forward-only migrations")
세 가지 결과:
- Breaking 컬럼 변경은 3단계로 나눕니다.
NOT NULL추가, 컬럼 drop, rename은 새 컬럼을 nullable로 추가하는 expand, 별도 revision이나 일회성 Celery task로 처리하는 migrate data, 기존 컬럼을 drop하거나 NOT NULL을 설정하는 contract 순서로 나누고, 한 revision에 결합하지 않습니다. - 스키마와 데이터 마이그레이션은 별도 revision. 스키마 revision에는 몇 행 이상의
bulk_insert를 넣지 않습니다. 더 큰 데이터 이동은 멱등한 일회성 Celery task로 작성하고 별도data_xxxx_*revision에서 큐에 넣으세요. - 새 테이블을 추가할 때는 런타임 권한을 판단합니다. 런타임 롤
trustedoss_app이 그 테이블에UPDATE나DELETE를 실행해야 한다면, 같은 마이그레이션의upgrade()에GRANT UPDATE, DELETE ON <table> TO trustedoss_app을 명시적으로 추가하세요. append-only 테이블(INSERT만 실행)이라면 GRANT는 필요 없지만apps/backend/tests/fixtures/app_role_privileges.json에 등재해야 합니다. 등재하지 않으면test_app_role_grant_matrix.py가 실패하며 둘 중 무엇을 해야 하는지 알려줍니다.
대형 테이블에 인덱스 추가하기 - CREATE INDEX CONCURRENTLY
alembic/env.py의 run_migrations_online()은 모든 마이그레이션을 pg_advisory_xact_lock으로 보호되는 트랜잭션 하나로 감쌉니다. 배포 중 alembic upgrade head가 여러 번 동시에 실행돼도 스키마가 깨지지 않고 순서대로 처리되게 하기 위해서입니다. 일반적인 op.create_index(...)는 이 트랜잭션을 그대로 물려받는데, 이는 대부분 문제가 되지 않습니다. Postgres가 인덱스를 만드는 동안 잠금을 유지하기는 하지만, "대형"이라 부를 만한 크기가 아니라면 그 DDL 자체가 빠르게 끝나기 때문입니다.
CREATE INDEX CONCURRENTLY는 예외입니다. 이 명령은 트랜잭션 안에서 아예 실행할 수 없습니다(Postgres가 CREATE INDEX CONCURRENTLY cannot run inside a transaction block 오류로 거부합니다). 테이블이 커져서 일반적인 인덱스 생성이 잠그는 시간 동안 운영 트래픽이 멈출 정도가 되면(지금 기준으로는 audit_logs가 해당합니다), 인덱스 마이그레이션에 Alembic이 제공하는 탈출구를 써야 합니다.
def upgrade() -> None:
with op.get_context().autocommit_block():
op.create_index(
"ix_audit_logs_actor_user_id",
"audit_logs",
["actor_user_id"],
postgresql_concurrently=True,
)
autocommit_block()은 진행 중이던 트랜잭션을 커밋하고 그 안의 DDL을 Postgres의 autocommit 모드로 실행한 뒤, 같은 리비전에 이어지는 내용을 위해 새 트랜잭션을 엽니다. 여기서 신경 써야 할 결과가 하나 있습니다. 트랜잭션을 커밋하는 순간 pg_advisory_xact_lock이 쥐고 있던 잠금도 CONCURRENTLY 빌드가 시작되기 전에 풀립니다. K8s 롤아웃의 여러 레플리카가 서로 경합하지 않게 해주던 동시성 보호 장치가 이 블록에 도달한 뒤로는 이 마이그레이션에 더 이상 적용되지 않습니다. 같은 순간 시작된 두 번째 alembic upgrade head가 이 마이그레이션을 기다려주지 않는다는 뜻입니다.
그래서 autocommit_block()이 들어간 리비전은 다른 마이그레이션과 똑같이 취급할 수 없습니다. 유지보수 시간대에 혼자, 손으로 실행합니다.
- 같은 시간에 다른
alembic upgrade head가 실행되지 않는지 확인합니다(배포 파이프라인을 잠시 멈추거나, 마이그레이션을 실행하는 레플리카를 하나로 줄입니다). head가 아니라 이 리비전을 정확히 지정해alembic upgrade <이 리비전>을 실행합니다. 영향 범위가 명령어 자체에 드러나게 하기 위해서입니다.- 실행 중에는
pg_stat_progress_create_index로 진행 상황을 지켜봅니다.CONCURRENTLY빌드가 중간에 끊기면(연결 끊김, statement timeout)INVALID상태의 인덱스가 남 을 수 있습니다. Postgres는 이 인덱스를 쓰지도, 조용히 다시 만들어주지도 않으므로DROP INDEX CONCURRENTLY로 지우고 다시 실행합니다. - 그 다음에야 나머지 배포를 정상적으로 재개합니다.
Python 의존성 - .txt를 고치고 .lock을 다시 만듭니다
requirements.txt와 requirements-dev.txt(직접 의존성, 각 pin마다 이유를 설명하는 주석이 붙어 있습니다)는 사람이 직접 고치는 파일입니다. Dockerfile들은 대신 requirements.lock / requirements-dev.lock에서 설치합니다(pip install --require-hashes -r ...). 직접 의존성과 전이(transitive) 의존성 전부를 정확한 버전과 wheel/sdist 해시로 고정한 파일이고, pip-compile로 생성합니다. 이렇게 해야 빌드가 조용히 더 새로운 전이 의존성 릴리스를 집어가는 일이 없고, 이미지의 SBOM·SLSA provenance 주장이 실제로 소스에서 재현 가능해집니다.
두 .txt 파일 중 하나를 고쳤다면 같은 커밋에서 해당 .lock을 다시 만드세요.
cd apps/backend
pip install pip-tools
pip-compile --generate-hashes --output-file=requirements.lock requirements.txt
pip-compile --generate-hashes --output-file=requirements-dev.lock requirements-dev.txt
lint (backend)가 둘 다 다시 만들어 커밋된 것과 비교합니다. .lock이 낡았으면 고칠 명령까지 담아 PR을 실패시킵니다. 위와 같은 --output-file 이름으로 다시 만드세요. pip-compile은 자신을 호출한 명령 자체를 파일 헤더 주석에 남기는데, 출력 경로가 다르면 실제 pin이 안 바뀌었어도 그 헤더 줄 하나 때문에 "낡음"으로 잡힙니다.
RFC 7807 — application/problem+json
모든 4xx·5xx 응답은 application/problem+json을 사용합니다. 기본 형태:
{
"type": "https://trustedoss.dev/problems/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "API key 'tos_a1b2c3d4_…' lacks the scan:trigger permission.",
"instance": "/v1/projects/42/scans"
}
type은 안정된 URI입니다(URI가 resolve되지 않아도 무방). 기계가 읽는 오류 코드로 취급합니다.title은 짧은 사람용 요약 — 사용자 입력을 보간하지 마세요.detail은 사용자 입력을 보간할 수 있지만 비밀값을 흘리지 마세요.instance는 오류를 만든 URL 경로입니다(request-id는 본문이 아니라X-Request-ID헤더).
도메인 특화 확장은 snake_case로 두고 OpenAPI에 등장하도록 Pydantic 모델로 등록합니다.
class GateFailedProblem(Problem):
type: Literal["https://trustedoss.dev/problems/gate-failed"]
failing_components: list[str]
license_findings: int
cve_findings: int
예외를 Problem으로 변환하는 과정은 단일 FastAPI exception_handler에서 일어납니다. 라우트에서 raw HTTPException을 반환하지 마세요.
structlog — JSON 라인, 컨텍스트 전파
로그는 JSON, 한 라인 한 이벤트입니다. structlog는 apps/backend/core/logging.py에서 구성됩니다. 모든 로그 라인은 다음을 포함합니다.
request_id— 인바운드 헤더X-Request-ID, 또는 미들웨어가 생성한 UUIDv7.user_id,team_id— 인증 미들웨어가 설정. 비인증 호출은None.task_name,task_id: worker에서 모든 task에 바인드합니다. 전에는 22개 모듈이task_name을, 12개가task_id를 손으로 넣었고, 둘 다 넣지 않은 열 개는 무엇이 돌았는지 로그에 남길 방법이 없었습니다.
worker에서 request_id는 그 task를 보낸 요청의 id입니다. 디스패치 시점에 메시지에 실리고 반대편에서 apps/backend/tasks/log_context.py의 시그널 핸들러가 다시 바인드하므로, HTTP 요청과 그 요청이 시작한 스캔이 같은 id를 공유합니다. beat가 보내는 task는 뒤에 요청이 없어 request_id를 갖지 않으며, 이 부재는 그 자체로 뜻이 있어 임의의 값으로 채우지 않습니다.
이 밖의 값은 task의 몫이라 task가 직접 바인드하고 해제합니다. 스캔 파이프라인의 scan_id, 스윕의 dry_run이 그렇습니다. 핸들러는 자기가 바인드한 것만 지웁니다.
binder 패턴:
log = structlog.get_logger().bind(component="scan-pipeline")
async def run_scan(scan_id: str) -> None:
bound = log.bind(scan_id=scan_id)
bound.info("scan.start")
...
bound.info("scan.finish", duration_ms=duration_ms)
PII 마스킹
평문 PII를 절대 로그에 남기지 마세요. 값은 mask_pii()를 거쳐야 합니다.
from core.security import mask_pii
log.info("auth.login.success", email=mask_pii(email))
# 출력: { ..., "email": "h****@n****.com" }
마스킹 대상: 이메일, 풀 네임, raw 토큰, API Key full secret(prefix는 무방), Webhook URL, OAuth code 쿼리 파라미터.
i18n 키 — <feature>.<screen>.<element> kebab-case
모든 UI 문자열은 apps/frontend/src/locales/{en,ko}/<namespace>.json에 둡니다. 키 형식:
notifications.inbox.empty-state-title
notifications.inbox.empty-state-body
notifications.preferences.toggle-email-tooltip
규칙:
- 세그먼트 최소 3개: feature → screen → element. 드물게 글로벌 헬퍼는 2개도 허용됩니다.
- 세그먼트 내 kebab-case. camelCase·snake_case 회피.
- EN과 KO는 서로 mirror. EN에 키를 추가하고 KO를 누락하면 CI의
i18next-parser드리프트 게이트가 실패합니다. - 문자열 연결 금지. ICU placeholder를 사용하세요 —
"badge.unread-count": "{{count}} unread".
컴포넌트에서 키를 제거하면 parser 드리프트 게이트가 EN/KO의 고아 키도 잡아냅니다 — 양쪽에서 제거하세요.
서버가 생성하는 산출물은 영어 전용
위 규칙은 apps/frontend의 UI 문자열을 다루며, 이것이 지역화 대상의 전부입니다. 백엔드가 스스로 생성하는 것들, 즉 알림 제목·본문(notifications/email.py, notifications/slack.py, notifications/teams.py), Excel 리포트 헤더(services/report_xlsx_service.py), PDF 리포트 텍스트, 모든 에러 응답의 RFC 7807 title/detail 텍스트는 전부 영어로 고정되어 있고 앞으로도 그렇습니다. 백엔드에는 i18n 프레임워크가 없습니다: gettext·babel도 없고, 요청 경로 어디에서도 Accept-Language를 읽지 않습니다.
이는 놓친 빈틈이 아니라 내린 결정입니다. 생성 문서와 발신 메시지를 번역하려면 독자적인 추출 도구·검토 절차·드리프트 게이트를 갖춘 두 번째 번역 표면을 새로 만들고 유지해야 하는데, 그 산출물 대부분(감사 로그 export, 웹훅 페이로드, 관리자가 트리거하는 리포트)은 UI 언어를 한국어로 설정한 당사자가 아니라 운영자가 읽습니다. 지역화된 리포트나 알림에 대한 실제 수요가 생기면 그때 다시 검토하되, 임기응변으로 일부만 번역하지는 않습니다. 일부 시트는 영어고 일부 는 한국어이며 숫자·날짜 형식은 코드가 우연히 고정한 로케일 그대로인 리포트는, 일관되게 영어인 리포트보다 못합니다.
# nosec과 # nosemgrep — 라인 안에서 정당화
CI는 bandit(Python)과 semgrep(다언어) 정적 분석을 돌립니다. SAST는 High+에서 hard-fail입니다. 정당하게 false positive로 입증된 경우에만 suppress하고 라인에서 정당화하세요.
data = pickle.loads(blob) # nosec B301: blob은 root 소유인 로컬 백업 볼륨에서 옴, 사용자 공급 아님
expression = re.compile(user_input) # nosemgrep: regex-from-user-input: `is_safe_regex()`로 상위에서 검증됨
형식:
# nosem grep: <rule-id>: <한 문장 정당화>
리뷰어는 정당화가 없거나 룰을 다루지 않는 suppress를 거절합니다. 같은 suppress가 여러 라인에 필요하면 문제 로직을 단일 함수로 추출해 한 곳에서 suppress하세요.
secret-scan은 파일이 아니라 이력을 읽습니다
gitleaks detect는 브랜치 HEAD에서 도달할 수 있는 커밋을 검사합니다. 체크아웃된 작업 트리를 보는 것이 아닙니다. 이 차이는 검사가 실패하기 전까지 드러나지 않는데, 실패하고 나면 달리 설명되지 않는 현상을 설명해 줍니다.
값을 지워도 발견은 사라지지 않습니다. 그 값을 넣은 커밋이 브랜치에 남아 있으므로 검사는 여전히 그것을 봅니다. 값을 지우고 커밋해서 푸시했는데 같은 발견으로 또 실패한다면 수정이 잘못된 것이 아닙니다. 애초에 보고된 대상이 작업 트리가 아니었을 뿐입니다. 여기서 CI를 두 번 더 돌리며 작업 트리만 고친 사례가 이미 있습니다.
다른 기여자의 브랜치가 내 풀 리퀘스트를 실패시키지는 않습니다. fetch-depth: 0은 "전부 가져온다"처럼 읽히지만 해당 ref의 이력 전체라는 뜻입니다. actions/checkout은 풀 리퀘스트 ref만 가져오므로 다른 브랜치는 체크아웃에 아예 없습니다. 실행 로그도 같은 것을 보여 줍니다. 각 풀 리퀘스트는 저장소 전체가 아니라 main의 커밋 수에 자기 커밋을 더한 만큼만 검사합니다.
발견을 없애려면 그 값을 담은 커밋 자체를 없애야 합니다. 작업 트리가 깨끗하고 브랜치 위에서 작업하는 사람이 없을 때 이렇게 합니다.
git fetch origin
git rebase origin/main # 합치기 전에 리베이스를 먼저
git reset --soft origin/main # 전체 diff가 스테이지된 변경 하나가 됩니다
git commit # 문제 값이 없는 커밋 하나
git push --force-with-lease
리베이스가 reset보다 먼저입니다. git reset --soft origin/main은 작업 트리를 유지한 채 브랜치를 지금 시점의 origin/main으로 옮깁니다. 그래서 브랜치를 만든 뒤 main이 앞서 나갔다면, 새로 만든 커밋 하나가 그사이 머지된 변경을 전부 되돌립니다. 내 파일은 모두 멀쩡하므로 이것은 조용히 일어납니다. 이 저장소에서 실제로 일어난 일입니다. 머지된 풀 리퀘스트 두 개의 125줄이 그렇게 사라졌고, 단서는 변경 내용에 비해 삭제 줄 수가 지나치게 많은 diffstat 하나뿐이었습니다. 커밋하기 전에 git diff --stat origin/main을 읽고, git diff --name-only origin/main에 자신이 건드리지 않은 파일이 없는지 확인하세요.
값이 실제 자격증명이었다면 교체하세요. 원격에 올라간 이상 이후 이력이 어떻게 보이든 노출된 것으로 다뤄야 합니다. 이력을 다시 쓰는 것은 다음 검사에서 감추는 것이지 공개를 되돌리는 것이 아닙니다.
.gitleaks.toml에 값을 추가하는 것은 그것이 시크릿이 아님을 증명할 수 있을 때만이고, 근거를 옆 주석에 남깁니다. "CI에서만 쓴다"는 것은 정황이지 증명이 아닙니다.
AI 리뷰 코멘트 — 참고용이며 게이트가 아닙니다
PR에 AI security review (findings-driven) 제목의 코멘트가 붙을 수 있습니다. .github/workflows/ai-review.yml이 남기는 것으로, 바뀐 파일만 대상으로 semgrep을 다시 돌린 뒤 — SAST 게이트가 쓰는 severity 필터와 --error 플래그는 빼고 — 탐지된 항목을 모델이 실제 취약점과 오탐으로 나눈 결과입니다.
판정이 아니라 읽는 순서로 보세요. 머지를 막지 않으며 브랜치 보호의 필수 체크로 넣어서도 안 됩니다. 게이트는 secret-scan과 sast가 맡습니다. 오탐이라는 판정이 수정을 건너뛸 근거가 되지 않고, 실제 취약점이라는 판정도 위에서 설명한 라인 안 정당화 없이 suppress를 머지할 근거가 되지 않습니다.
입력이 어디서 오는지에서 두 가지가 따라옵 니다. 이 코멘트는 PR에 담긴 코드와 도구 메시지를 인용하므로, 포크에서 올라온 PR이라면 남이 쓴 문장입니다. 판정만 읽고 그 안에 섞인 지시는 따르지 마세요. 그리고 저장소에 ANTHROPIC_API_KEY 시크릿이 없으면 워크플로우는 동작하지 않습니다. 키가 없으면 모든 단계를 건너뛰고 job은 성공하며 호출도 일어나지 않습니다. 코멘트가 보이지 않는다면 지금은 그것이 정상입니다.
파싱과 상한, 코멘트 조립은 tools/ai-review/에 있습니다. python tools/ai-review/selftest.py가 네트워크 없이 이 부분을 검증하고, 모든 PR에서 lint (backend)의 일부로 돌아갑니다.
MCP 서버와 에이전트 도구
이 저장소의 상당 부분은 AI 코딩 에이전트로 작성하며, 에이전트의 도구 호출은 PR을 거치지 않습니다. MCP(Model Context Protocol) 서버는 자기 도구 설명을 에이전트의 컨텍스트로 넘기고 저장소 밖 서비스에 닿을 수 있으므로, 개인 설정에 조용히 추가된 서버는 아무도 검토하지 않은 빌드 입력이 됩니다.
승인된 서버는 저장소 최상위의 .mcp.json에 선언합니다. 이 파일은 저장소에 포함되어 있고 지금은 비어 있습니다. 서버를 추가한다는 것은 개인 설정만 고치는 것이 아니라 이 파일을 PR로 고친다는 뜻이고, 그래야 추가 사실이 남이 읽을 수 있는 diff로 드러납니다.
서버를 들이기 전에 네 가지를 확인하고 그 결과를 PR에 적으세요.
- 출처. 게 시자, 어느 저장소에서 만들어졌는지, 최근 커밋이 있는지 봅니다. 마켓플레이스나 레지스트리 등재는 심사를 통과했다는 뜻이 아닙니다.
- 도구 설명. 직접 읽으세요. 도구 설명은 지시문으로 에이전트의 컨텍스트에 들어가므로, 숨은 지시가 담긴 설명은 시스템 프롬프트를 고친 것과 같은 효과를 냅니다.
- 데이터 반출 경로. 서버가 어떤 외부 엔드포인트와 통신하는지, 이 저장소의 내용이 그 경로로 나갈 수 있는지 판정합니다.
- 버전. 고정하세요. 승인 시점에 정상이던 패키지가 이후 버전에서 바뀔 수 있고, 실제로 그렇게 악성으로 바뀐 MCP 서버들이 있습니다.
버전을 올릴 때는 도구 설명을 다시 읽으세요. 서버 갱신이 설명을 바꿔 놓을 수 있습니다.
들이기 전에 후보 서버를 한 번 스캔하세요(Snyk의 agent-scan, 또는 Cisco의 mcp-scanner). 두 도구 모두 검사 과정에서 서버를 실제로 실행하므로, 평소 작업하는 컴퓨터가 아니라 컨테이너나 버려도 되는 환경에서 돌립니다.
에이전트 스킬과 IDE 확장도 똑같이 다룹니다. 이들은 여러분의 권한으로 실행되고, 어느 lockfile에도 나타나지 않으며, 이 저장소의 어떤 SCA 실행도 이들을 보지 못합니다. 설치된 목록을 분기에 한 번 다시 확인하세요.
프론트엔드 UI — 공용 프리미티브를 쓰세요
프론트엔드는 단일 디자인 시스템을 따릅니다. 디자인 시스템 레퍼런스가 토큰, 컴포넌트, 모션의 단일 진실입니다. 프리미티브가 이미 다루는 것을 직접 만들지 마세요:
- 페이지 헤더 — 모든 라우트 헤더를
PageHeader(stacked / bar)로 렌더링. bare<header><h1>금지(브레드크럼 detail 페이지만 문서화된 예외). - 타이포그래피 —
typography.tsx프리미티브(PageTitle/SectionTitle/Subtitle/Body/Caption/Eyebrow)를 쓰고, rawtext-*유틸리티는 일회성 인라인 span 에만. - 피드백 — 성공 · 비차단 알림은
useToast(), 폼 검증 에러는 인라인(RFC 7807detail). 토스트는data-toast-keye2e 계약을 가짐 — 안정적인key를 넘기세요. - 빈 / 로딩 — 임시 바가 아니라
EmptyState(레이어드 메달리온)와skeletons.tsx의 composite 스켈레톤. - 색 — 컴포넌트에서 hex 리터럴 금지, 토큰 / Tailwind 유틸리티(
bg-card,text-risk-high)를 참조.
모든 프리미티브는 /dev/design-preview(살아있는 레퍼런스)에서 눈으로 확인하세요. 새 공용 프리미티브는 거기에 추가합니다.