본문으로 건너뛰기

스캔 보존(retention)

CI·웹훅 자동화는 push·pull request·merge request 마다 스캔을 트리거합니다. 제한이 없으면 프로젝트당 거의 동일한 스냅샷이 수천 개 쌓입니다. 포털은 이력을 유용하게, 디스크를 유한하게 유지하기 위해 보존 모델을 둡니다 — 타겟별 최신 성공 스캔만 live로 남고, 더 오래된 스냅샷은 superseded 되어 grace 윈도우 후 회수되며, release로 명시한 스캔은 영구 보존됩니다.

대상 독자

자동 스캔을 수신하는 포털을 운영하는 super_admin 및 team_admin. .env 편집과 docker-compose restart에 익숙해야 합니다. 스캔 단위 라이프사이클(queued → running → succeeded)은 스캔을 참고하십시오.

보존 모델​

모든 스캔은 자신의 프로젝트와 실행 대상 브랜치·PR의 정규화된 ref에서 파생된 보존 타겟을 가집니다. 동일한 타겟에 더 새로운 성공 스캔이 도착하면 이전 스캔은 superseded 됩니다.

target = (project_id, normalized_ref)

scan #1 on main ──► live
scan #2 on main ──► live, scan #1 superseded ──► grace 후 회수
scan #3 on main ──► live, scan #2 superseded ──► grace 후 회수
  • Live — 타겟의 가장 최근 성공 스캔. 항상 조회 가능하며 나이만으로는 회수되지 않습니다.
  • Superseded — 동일 타겟의 더 새로운 성공 스캔으로 대체된, 이전에 live였던 스캔. diff·롤백을 위해 grace 윈도우(SCAN_RETENTION_SUPERSEDED_GRACE_DAYS, 기본 7일) 동안 보존된 뒤 sweep이 회수합니다.
  • Release — metadata.release 라벨이 설정된 스캔. 영구입니다 — 나이나 supersession과 무관하게 sweep이 절대 삭제하지 않습니다. 같은 라벨을 주장하는 다른 스캔만이 이를 Releases 목록에서 밀어낼 수 있고, 그 경우에도 행 자체는 남습니다. 스캔을 영구 보존하는 법을 참고하십시오.
  • Ref-less / 실패 — ref 타겟이 없는 스캔(ad-hoc UI 스캔)과 실패 스캔은 supersession 체인에 속하지 않습니다. 프로젝트당 하한(SCAN_RETENTION_KEEP_LAST, 기본 30)과 age 상한(SCAN_RETENTION_MAX_AGE_DAYS, 기본 180)으로 보호됩니다.

ref 정규화​

보존 타겟은 ref의 정규화된 형태를 사용하므로, CI가 어떻게 표기하든 동일한 논리적 브랜치·PR이 함께 묶입니다. 포털은 metadata.ref로 수신한 ref를 다음과 같이 정규화합니다.

수신 ref정규화비고
refs/heads/mainmain브랜치 ref는 refs/heads/ prefix를 제거합니다.
refs/pull/12/mergepr-12GitHub PR merge ref는 pr-<number>가 됩니다.
refs/merge-requests/7/headmr-7GitLab MR ref는 mr-<iid>가 됩니다.
main, release/2.0main, release/2.0bare 브랜치명은 그대로 유지합니다.

GitHub Action은 github.ref(또는 PR 번호)를, GitLab CI 템플릿은 CI_COMMIT_REF_NAME / MR IID를 전달하므로 설정 없이 올바르게 그룹화됩니다. Jenkinsfile 스니펫도 동일하게 BRANCH_NAME을 전달합니다.

보존 정책 변수​

네 키 모두 런타임에 os.getenv로 읽힙니다 — .env를 편집하고 Celery 워커와 beat 서비스를 재시작하면 적용됩니다. 서비스명은 스택마다 다릅니다. 프로덕션 compose(docker-compose.yml)에서는 worker·beat이고, dev compose(docker-compose.dev.yml)에서는 celery-worker·celery-beat입니다. 정식 레퍼런스는 환경변수 → 스캔 보존을 참고하십시오.

# 포털의 .env
SCAN_RETENTION_SUPERSEDED_GRACE_DAYS=7 # superseded 스냅샷을 회수 전 이만큼 보존
SCAN_RETENTION_KEEP_LAST=30 # ref-less / 실패 스캔의 프로젝트당 하한
SCAN_RETENTION_MAX_AGE_DAYS=180 # ref-less / 실패 스캔의 age 상한
키기본값효과
SCAN_RETENTION_SUPERSEDED_GRACE_DAYS7superseded 스냅샷이 sweep에 회수되기 전 생존하는 일수. 브랜치별 롤백 이력을 더 길게 유지하려면 높이십시오.
SCAN_RETENTION_KEEP_LAST30나이와 무관하게 프로젝트당 보존되는 ref-less·실패 스캔의 최소 개수. sweep은 이 하한 아래로 트림하지 않습니다.
SCAN_RETENTION_MAX_AGE_DAYS180ref 없는 성공 스캔과 실패/취소 스캔 중 이보다 오래된 것(그리고 keep-last 하한을 넘은 것)이 회수됩니다. ref의 live 스냅샷과 release 라벨 스캔은 예외입니다 — 이들은 나이가 아니라 retire가 관리합니다.
값을 낮추면 더 일찍 회수됩니다

SCAN_RETENTION_SUPERSEDED_GRACE_DAYS나 SCAN_RETENTION_MAX_AGE_DAYS를 낮추면 다음 sweep이 더 많은 스냅샷을 회수합니다. sweep은 되돌릴 수 없습니다 — 회수된 스캔과 그 결과는 사라집니다. 먼저 올려서 디스크를 관찰한 뒤 낮추십시오.

보존 sweep​

회수는 스캔 완료 시 동기적으로가 아니라 6시간 주기 Celery beat 태스크로 실행됩니다. superseded 마킹은 더 새로운 성공이 도착하는 즉시 일어나며, 디스크·DB 행은 grace 윈도우를 지난 스냅샷을 다음 sweep이 발견할 때 회수됩니다.

각 sweep은:

  1. SCAN_RETENTION_SUPERSEDED_GRACE_DAYS보다 오래된 superseded 스냅샷을 회수합니다.
  2. ref 없는 성공 스캔과 모든 실패/취소 스캔 중 프로젝트당 최신 SCAN_RETENTION_KEEP_LAST개를 보존하고, 나머지 중 SCAN_RETENTION_MAX_AGE_DAYS보다 오래된 것을 회수합니다.
  3. ref의 live 스냅샷(sweep이 아니라 retire가 관리)과 metadata.release 라벨이 붙은 스캔은 절대 건드리지 않습니다.

스캔 회수는 그 workspace 아티팩트(소스 클론, cdxgen SBOM, scancode 출력)와 DB 행(컴포넌트·라이선스·결과)을 삭제합니다. 감사 로그는 회수된 스캔마다 사유 — superseded(1단계) 또는 aged(2단계) — 와 함께 scans delete 이벤트를 기록합니다.

스캔을 영구 보존하는 법 (release 라벨)​

보존이 절대 회수하지 않도록 스캔을 고정하려면 — 예를 들어 태그 릴리스를 뒷받침하는 스캔 — 트리거 시 metadata.release 라벨을 설정하십시오. release 라벨 스캔은 불변입니다 — grace 윈도우·age 상한·keep-last 트림에서 모두 면제됩니다.

curl -sS -X POST \
"https://trustedoss.example.com/v1/projects/${PROJECT_ID}/scans" \
-H "Authorization: Bearer ${TRUSTEDOSS_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"kind": "source", "metadata": {"ref": "refs/tags/v2.0.0", "release": "v2.0.0"}}' | jq .

release 값은 자유 형식 라벨입니다(버전 문자열이 관례). CI에서는 태그 push에서 실행되는 워크플로에만 설정하여, 일상적 브랜치·PR 스캔은 회수 가능한 상태로 두고 release 스캔만 영구 컴플라이언스 기록으로 누적되게 하십시오.

브랜치 트래픽은 릴리스를 밀어내지 않습니다

라벨이 서로 다른 두 릴리스는 같은 브랜치에 있어도 나란히 live로 남고, 그 브랜치에 push가 들어와도 둘 다 물러나지 않습니다. 라벨을 두는 이유가 이것입니다. 브랜치 포인터는 계속 움직이지만, 출시한 버전은 그 브랜치의 무관한 트래픽 때문에 물러나서는 안 됩니다.

이미 라벨을 붙인 버전을 다시 스캔하면​

앞선 스캔이 이미 쓰고 있는 라벨을 두 번째 스캔에 붙일 수 있고, 이때는 새 스캔이 이깁니다. 앞선 스캔에 superseded_at이 찍히고 Releases 목록에서 빠집니다. 출시한 버전을 다시 스캔하는 일은 흔합니다. 첫 시도가 실패했거나, 스캐너가 개선되었거나, 라벨에 오타가 있었을 수 있습니다. 그래서 포털은 스캔을 거부해 빠져나갈 길을 막는 대신 라벨을 옮깁니다.

여기서 supersession은 삭제를 뜻하지 않습니다. 회수 sweep은 모든 경로에서 라벨이 붙은 스캔을 건너뛰므로, 밀려난 스냅샷은 컴포넌트·결과·SBOM을 그대로 유지하고 GET /v1/scans/{id}와 ?scan_id= 앵커로 계속 읽을 수 있습니다. 옮겨 가는 것은 라벨에 대한 소유권뿐입니다. 이전 스냅샷을 Releases 목록에 되돌리려면 그 라벨로 다시 스캔하거나, 애초에 라벨을 다르게 붙여(4.0과 4.0-rescan) 둘을 갈라 두십시오.

라벨은 앞뒤 공백을 제거하고 비교하므로 4.0과 " 4.0"은 같은 버전이며 둘 다 live일 수 없습니다.

스캔 수동 삭제​

sweep을 기다리지 않고 단일 스캔을 즉시 회수하려면 DELETE /v1/scans/{scan_id}를 사용하십시오 — 예를 들어 잘못된 프로젝트에 트리거한 스캔이나, 이력에 두고 싶지 않은 노이즈 스냅샷.

curl -sS -X DELETE \
"https://trustedoss.example.com/v1/scans/${SCAN_ID}" \
-H "Authorization: Bearer ${TRUSTEDOSS_API_KEY}" | jq .

metadata.release 라벨이 붙은 스캔을 삭제하려면 ?force=true를 추가하십시오.

curl -sS -X DELETE \
"https://trustedoss.example.com/v1/scans/${SCAN_ID}?force=true" \
-H "Authorization: Bearer ${TRUSTEDOSS_API_KEY}" | jq .

권한 및 응답​

조건요구 / 응답
호출자 역할소유 팀의 developer 이상. force=true는 team_admin 이상 필요.
타 팀의 스캔404 Not Found — 타 팀 스캔은 존재 은닉됩니다. 404가 스캔 존재를 확인해 주지 않습니다.
스캔이 queued·running409 Conflict — RFC 7807 본문에 scan_active: true가 담깁니다. 활성 스캔은 삭제할 수 없으니 먼저 취소한 뒤 삭제하십시오.
release 라벨 보유, force 없음409 Conflict — RFC 7807 본문에 scan_release_protected: true가 담깁니다. team_admin으로 ?force=true를 붙여 재요청하십시오.
비-team_admin의 force=true403 Forbidden — release 보호 스캔의 강제 삭제는 team_admin이 필요합니다.
삭제됨204 No Content. 아티팩트·행이 사라지며 감사 로그가 scans delete 이벤트를 기록합니다.

정상 동작 확인​

  1. 같은 브랜치에 소스 스캔 두 번을 트리거합니다. 더 오래된 스캔을 GET /v1/scans/{id}로 조회하면 superseded_at이 설정돼 있습니다. 프로젝트 Releases 목록(GET /v1/projects/{id}/releases)에는 더 새로운 스냅샷만 보이고, superseded된 것은 거기서 숨겨집니다.
  1. release 라벨을 단 스캔과 같은 브랜치의 라벨 없는 두 번째 스캔을 트리거합니다. release 스캔의 superseded_at은 null로 남고 Releases 목록에 그대로 유지됩니다 — 브랜치 트래픽은 이를 밀어내지 않습니다.
  1. 같은 release 라벨을 단 두 번째 스캔을 트리거합니다. 이제 첫 번째 스캔에 superseded_at이 찍히고 Releases 목록에는 새 스냅샷만 남습니다. 그래도 GET /v1/scans/{first_id}는 여전히 응답하고, 어떤 sweep도 이를 삭제하지 않습니다.
  1. 라벨 없는 superseded 스캔이 grace 윈도우를 지난 뒤, 다음 6시간 주기 sweep이 그것을 제거합니다. 감사 로그로 확인하십시오 — 사유 superseded의 scans delete 이벤트.
  1. release가 아닌 terminal 스캔에 대한 DELETE /v1/scans/{scan_id}는 204를 반환하고 스캔이 이력에서 사라집니다.

트러블슈팅​

먼저 확인할 로그
  • docker-compose -f docker-compose.yml logs --tail=200 beat | grep scan_retention_done — 마지막 sweep의 판정과 사유별 카운트(reclaimed_superseded / reclaimed_aged). 각 sweep 시작 시 적용된 정책은 scan_retention_policy로 로그에 남습니다. dev compose에서는 서비스명이 beat가 아니라 celery-beat입니다.
  • scans delete로 필터링한 감사 로그 — 무엇이 왜 회수되었는지.

같은 브랜치의 두 스캔이 모두 live로 남음​

같은 정규화 타겟에 있지 않거나, 둘 중 하나에 release 라벨이 붙어 있는 것입니다. 먼저 둘 다 동일한 metadata.ref를 전달했는지 확인하십시오. bare 브랜치명(main)과 fully-qualified ref(refs/heads/main)는 같은 타겟으로 정규화되지만, PR merge ref(refs/pull/12/merge → pr-12)는 base 브랜치와 별개 타겟입니다 — 이는 의도된 동작입니다. 그다음 양쪽의 metadata.release를 확인하십시오. 라벨이 붙은 스캔은 같은 라벨을 주장하는 다른 스캔에만 밀려나며, 브랜치 트래픽으로는 밀려나지 않습니다.

회수될 것이라 기대한 스캔이 그대로 있음​

순서대로 확인하십시오.

  • 타겟의 live 스냅샷입니다 — live 스캔은 superseded 되거나 SCAN_RETENTION_MAX_AGE_DAYS를 초과하기 전까지 나이로 회수되지 않습니다.
  • metadata.release 라벨이 붙어 있습니다 — release 스캔은 영구입니다. UI나 API에서 스캔 metadata를 확인하십시오.
  • SCAN_RETENTION_KEEP_LAST 하한 안에 있습니다 — 프로젝트당 최신 N개에 든 ref-less·실패 스캔은 나이와 무관하게 보호됩니다.
  • grace 윈도우가 아직 지나지 않았습니다 — superseded 스캔은 sweep이 가져가기 전 SCAN_RETENTION_SUPERSEDED_GRACE_DAYS 동안 생존합니다.

sweep 회수보다 디스크가 더 빨리 참​

sweep은 6시간 주기로 실행되므로 CI 부하가 큰 날에는 추월당할 수 있습니다. SCAN_RETENTION_SUPERSEDED_GRACE_DAYS를 낮춰 superseded 스냅샷을 더 일찍 회수하거나, 최악의 항목을 수동 삭제하십시오. workspace 수준 아티팩트 정리는 디스크·health → 디스크가 찼을 때를 참고하십시오.

스캔 삭제 시 409​

스캔이 아직 queued·running이거나(먼저 취소 — 스캔 취소) release 라벨이 있는데 ?force=true를 전달하지 않은 것입니다. RFC 7807 본문의 확장 필드가 어느 쪽인지 알려 줍니다 — scan_active: true 대 scan_release_protected: true.

함께 보기​