Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a8fc068
test(kpi): reject provenance/log identity drift
seonghobae Aug 10, 2026
a8117ce
fix(kpi): verify exact production log identity
seonghobae Aug 10, 2026
0ab09d0
fix(kpi): record collected log digest and size
seonghobae Aug 10, 2026
3f02fef
test(kpi): cover strict provenance identity contracts
seonghobae Aug 10, 2026
e12e41d
test(kpi): verify collector binds provenance to bytes
seonghobae Aug 10, 2026
43054db
docs(kpi): define exact log provenance identity
seonghobae Aug 10, 2026
00db6c3
test(kpi): expose provenance snapshot race
seonghobae Aug 10, 2026
b9c5158
test(kpi): bind snapshot race assertion to log identity
seonghobae Aug 10, 2026
e80c6a8
fix(kpi): score a verified private log snapshot
seonghobae Aug 10, 2026
41fa0f7
docs(kpi): define verified snapshot scoring boundary
seonghobae Aug 10, 2026
676f311
test(kpi): expose unterminated NDJSON provenance count
seonghobae Aug 10, 2026
61b8c15
fix(kpi): derive record count from hashed bytes
seonghobae Aug 10, 2026
5b97a85
docs(kpi): align record-count provenance contract
seonghobae Aug 10, 2026
807a867
test(kpi): fail when strict evidence cannot persist
seonghobae Aug 10, 2026
b4055f3
fix(kpi): fail closed on strict evidence write errors
seonghobae Aug 10, 2026
825dd9b
test(kpi): cover positive log byte mismatch
seonghobae Aug 10, 2026
79846fa
test(kpi): reject symlink evidence sinks
seonghobae Aug 10, 2026
37aab89
fix(kpi): refuse symlink evidence sinks
seonghobae Aug 10, 2026
19b49cb
fix(kpi): use filesystem no-follow constants
seonghobae Aug 10, 2026
3d8a7da
test(kpi): fail closed on unsafe log URL transport
seonghobae Aug 11, 2026
e584406
fix(kpi): constrain log URL transport
seonghobae Aug 11, 2026
452345a
test(kpi): isolate child process environment
seonghobae Aug 11, 2026
01eaf45
fix(security): isolate KPI subprocess authority
seonghobae Aug 12, 2026
bfbb7ec
test(kpi): prove failed collection retains no bytes
seonghobae Aug 12, 2026
a5af2eb
chore(kpi): merge latest protected main
seonghobae Aug 12, 2026
b834107
Merge branch 'main' into fix/kpi-provenance-integrity-on-main
opencode-agent[bot] Aug 13, 2026
43ed054
Merge branch 'main' into fix/kpi-provenance-integrity-on-main
opencode-agent[bot] Aug 13, 2026
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
13 changes: 8 additions & 5 deletions docs/observability-kpi.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ node scripts/evaluate-observability-alerts.mjs exchange-30d.ndjson
```

`exchange-30d.ndjson`은 `wrangler tail --format json` 출력에서 저장한 행 기반 로그 파일입니다.
`exchange-30d.ndjson.provenance.json`은 `kpi:collect`가 생성하는 운영 출처 증빙이며, strict 게이트는 이 파일을 요구합니다.
strict 게이트의 `sourceId`는 `cloudflare-logpush:noema-production`처럼 비밀이 아닌 안정적 라벨이어야 하며, placeholder, URL, query string, token, secret, API/private/access key 형태는 실패 처리됩니다.
`exchange-30d.ndjson.provenance.json`은 `kpi:collect`가 생성하는 운영 출처 증빙이며, strict 게이트는 이 파일을 요구합니다. 수집기는 단일 파일 스트림에서 `records`, `logSha256`, `logBytes`를 함께 계산합니다. `records`는 LF(`\n`) 개수에 마지막 비종결(non-empty) NDJSON 레코드를 더하므로 마지막 줄에 개행 문자가 없어도 한 레코드로 계산되며, 별도 `wc -l` 결과를 provenance 권위로 사용하지 않습니다. strict 게이트는 원본 로그의 identity가 provenance와 일치하는지 먼저 검증한 뒤 프로세스 전용 임시 디렉터리에 읽기 전용(`0400`) 스냅샷을 만들고, 스냅샷의 SHA-256/바이트 수를 provenance와 다시 대조합니다. KPI check/alert 하위 프로세스에는 원본 경로가 아니라 이 검증된 스냅샷 경로만 전달하므로, 검사 중 원본 로그가 교체되었다가 복원되어도 실제 계산 바이트는 바뀌지 않습니다. 하위 검사가 끝난 뒤 원본 identity도 다시 확인하므로 복원되지 않은 drift는 별도로 실패 폐쇄합니다. 이 보장은 동일 OS 계정 또는 더 높은 권한으로 임시 스냅샷 자체를 조작하는 비신뢰 프로세스까지 격리한다는 의미는 아닙니다.
strict 게이트의 `sourceId`는 `cloudflare-logpush:noema-production`처럼 비밀이 아닌 안정적 라벨이어야 하며, placeholder, URL, query string, token, secret, API/private/access key 형태는 실패 처리됩니다. `logSha256`은 소문자 64자리 SHA-256이어야 하고 `logBytes`는 양의 safe integer여야 합니다.
이 바이트 결합은 보존된 KPI 로그와 provenance의 동일성만 증명합니다. 외부 Logpush/아카이브 자체의 신뢰성, 운영 배포 상태, 고객·매출 증빙, release/provenance authority 또는 법적 권리를 대신 증명하지 않습니다.
`scripts/compute-kpi.mjs`는 `request.url`, `request.path`, `route`, `status`, `latency_ms` 등 여러 필드 규격을 동시에 해석합니다.
`check-kpi`는 `NOEMA_KPI_REQUIRE_WINDOW_DAYS`를 통해 최소 구간(운영 게이트 기본 30일)을 검증합니다.

Expand All @@ -75,6 +76,8 @@ strict 게이트의 `sourceId`는 `cloudflare-logpush:noema-production`처럼
- [ ] Logpush/로그 아카이브에서 `/exchange` 포함 `http_request` 로그를 30일치 추출
- [ ] 추출 파일을 `exchange-30d.ndjson`로 저장
- [ ] `NOEMA_KPI_SOURCE_KIND=production`, `NOEMA_KPI_SOURCE_ID=<비밀 아닌 출처 라벨>`로 provenance 생성
- [ ] provenance의 `records`가 양수이고, 마지막 개행 유무와 무관하게 수집 파일의 NDJSON 레코드 수를 나타내는지 확인
- [ ] provenance의 `logSha256`/`logBytes`가 수집 직후 `exchange-30d.ndjson`의 실제 바이트 identity와 일치하는지 확인
- [ ] `npm run kpi:check -- exchange-30d.ndjson 0.02 300` 통과
- [ ] `npm run kpi:alerts -- exchange-30d.ndjson` 경보 0건 확인
- [ ] `NOEMA_KPI_LOG_PATH=exchange-30d.ndjson NOEMA_KPI_PROVENANCE_PATH=exchange-30d.ndjson.provenance.json npm run kpi:verify:strict` 통과
Expand Down Expand Up @@ -112,9 +115,9 @@ npm run kpi:verify:strict

### Logpush/외부 아카이브 수집(권장)

- 아카이브가 NDJSON(한 줄 JSON) `NOEMA_KPI_LOG_URL`로 바로 수집
- 아카이브가 NDJSON(한 줄 JSON)이면 `NOEMA_KPI_LOG_URL`로 바로 수집
- 수집 시 `NOEMA_KPI_SOURCE_KIND=production` 및 비밀이 아닌 안정적 출처 라벨(`NOEMA_KPI_SOURCE_ID`)을 반드시 지정
- `kpi:collect`는 위 source metadata가 없거나 source id가 placeholder/URL/query/token/secret/API-private-access-key 형태이면 수집 전 실패
- `kpi:collect`는 `exchange-30d.ndjson.provenance.json`을 생성하며 strict 모드는 파일 없이는 실패
- `kpi:collect`는 동일한 retained-byte 스트림에서 `records`/`logSha256`/`logBytes`를 계산하여 `exchange-30d.ndjson.provenance.json`에 기록하며 strict 모드는 provenance가 없거나 실제 파일 identity와 다르면 실패
- 압축 파일은 미리 `gzip -dc`로 해제 후 `exchange-30d.ndjson`로 저장
- 수집 후 `wc -l exchange-30d.ndjson`로 30일치 최소 데이터가 확보되었는지 선검증
- 수집 후 provenance의 `records`, `logSha256`, `logBytes`를 보존 파일과 함께 검토하고 strict gate로 재검증
72 changes: 56 additions & 16 deletions scripts/collect-kpi-logs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,19 @@ echo "Collecting KPI logs to ${TARGET_FILE}..."
SOURCE_METHOD=""
if [[ -n "${NOEMA_KPI_LOG_URL:-}" ]]; then
SOURCE_METHOD="log-url"
if ! curl -sS "${NOEMA_KPI_LOG_URL}" -o "${TARGET_FILE}"; then
if ! node --input-type=module <<'NODE'
try {
const sourceUrl = new URL(process.env.NOEMA_KPI_LOG_URL);
if (sourceUrl.protocol !== "https:") throw new Error("unsupported protocol");
} catch {
console.error("ERROR: NOEMA_KPI_LOG_URL must be an absolute HTTPS URL.");
process.exit(1);
}
NODE
then
exit 1
fi
if ! curl --proto '=https' --fail --silent --show-error "${NOEMA_KPI_LOG_URL}" -o "${TARGET_FILE}"; then
echo "Failed to download KPI logs from NOEMA_KPI_LOG_URL."
exit 1
fi
Expand All @@ -79,30 +91,58 @@ if [[ ! -s "${TARGET_FILE}" ]]; then
exit 1
fi

RECORDS="$(wc -l < "${TARGET_FILE}" | tr -d '[:space:]')"
export NOEMA_KPI_PROVENANCE_FILE="${PROVENANCE_FILE}"
export NOEMA_KPI_PROVENANCE_LOG_PATH="${TARGET_FILE}"
export NOEMA_KPI_PROVENANCE_RECORDS="${RECORDS}"
export NOEMA_KPI_SOURCE_METHOD="${SOURCE_METHOD}"

node <<'NODE'
const crypto = require("node:crypto");
const fs = require("node:fs");

const provenancePath = process.env.NOEMA_KPI_PROVENANCE_FILE;
const payload = {
sourceKind: process.env.NOEMA_KPI_SOURCE_KIND,
sourceId: process.env.NOEMA_KPI_SOURCE_ID,
sourceMethod: process.env.NOEMA_KPI_SOURCE_METHOD || null,
logPath: process.env.NOEMA_KPI_PROVENANCE_LOG_PATH,
records: Number(process.env.NOEMA_KPI_PROVENANCE_RECORDS || "0"),
collectedAt: new Date().toISOString(),
redaction: "Source URL and tail command are not persisted; set NOEMA_KPI_SOURCE_ID to a stable non-secret source label.",
};

fs.writeFileSync(provenancePath, `${JSON.stringify(payload, null, 2)}\n`);
async function main() {
const provenancePath = process.env.NOEMA_KPI_PROVENANCE_FILE;
const logPath = process.env.NOEMA_KPI_PROVENANCE_LOG_PATH;
const hash = crypto.createHash("sha256");
let logBytes = 0;
let newlineCount = 0;
let lastByte = null;

for await (const chunk of fs.createReadStream(logPath)) {
hash.update(chunk);
logBytes += chunk.length;
for (const byte of chunk) {
if (byte === 0x0a) newlineCount += 1;
}
if (chunk.length > 0) lastByte = chunk[chunk.length - 1];
}

const records = newlineCount + (logBytes > 0 && lastByte !== 0x0a ? 1 : 0);
if (!Number.isSafeInteger(records) || records <= 0) {
throw new Error("Collected KPI log has no countable NDJSON records.");
}

const payload = {
sourceKind: process.env.NOEMA_KPI_SOURCE_KIND,
sourceId: process.env.NOEMA_KPI_SOURCE_ID,
sourceMethod: process.env.NOEMA_KPI_SOURCE_METHOD || null,
logPath,
records,
collectedAt: new Date().toISOString(),
logSha256: hash.digest("hex"),
logBytes,
redaction: "Source URL and tail command are not persisted; set NOEMA_KPI_SOURCE_ID to a stable non-secret source label.",
};

fs.writeFileSync(provenancePath, `${JSON.stringify(payload, null, 2)}\n`);
console.log(`Collected records: ${records}`);
}

main().catch((error) => {
console.error("Failed to compute KPI log provenance identity.", error);
process.exit(1);
});
NODE

echo "KPI logs saved to ${TARGET_FILE}"
echo "KPI provenance saved to ${PROVENANCE_FILE}"
echo "Collected records: ${RECORDS}"
exit 0
Loading
Loading