Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7a6ff36
fix(security): bump @hono/node-server to 2.0.12 and close CSV formula…
seonghobae Jul 31, 2026
868afdb
ci: re-kick Actions after org queue storm (security train #386)
seonghobae Jul 31, 2026
1ae3f57
fix(security): JWT secret fail-closed + MS Project XML ReDoS removal
seonghobae Jul 31, 2026
92df38c
fix(ci): drop vestigial pnpm-lock so OpenCode coverage uses npm
seonghobae Jul 31, 2026
2b2726a
fix(security): address CodeRabbit threads blocking merge-scheduler
seonghobae Jul 31, 2026
d42c8c1
docs(security): preserve JWT secret across compose restarts
seonghobae Jul 31, 2026
73778cc
test(coverage): emit real Istanbul changed-line evidence
seonghobae Jul 31, 2026
c5965e6
test(runtime): align coverage Node requirements
seonghobae Jul 31, 2026
a10e6ac
fix(runtime): require unflagged node sqlite
seonghobae Jul 31, 2026
dc916d2
fix(security): scrypt type-safe passwords + pure Istanbul coverage gate
seonghobae Aug 2, 2026
e71bfa4
fix(ux): beforeunload guard for dirty inline editor (#396)
seonghobae Aug 2, 2026
644e9fc
fix(security): reject non-string passwords in verify; align Node docs
seonghobae Aug 2, 2026
eca8874
ci: re-kick required checks after hung Strix quick scan
seonghobae Aug 2, 2026
4a0f118
test(coverage): prove changed-line Istanbul for OpenCode gate
seonghobae Aug 2, 2026
658332e
ci: re-kick required checks after hung Strix quick scan
seonghobae Aug 2, 2026
5bc5fce
ci: re-kick required checks after hung Strix quick scan
seonghobae Aug 2, 2026
ba6ff1c
docs(security): persist JWT signing key across restarts
seonghobae Aug 3, 2026
871cde3
ci(coverage): select npm explicitly for trusted offline evidence
seonghobae Aug 3, 2026
ca5be8c
docs(security): record release-blocking invariants
seonghobae Aug 3, 2026
874a626
docs(changelog): record security hardening
seonghobae Aug 3, 2026
d7d7218
ci: repair latest PR 386 review findings
seonghobae Aug 3, 2026
5ba6a97
fix(review): harden auth and unsaved draft regressions
github-actions[bot] Aug 3, 2026
bd9a515
docs(changelog): record review hardening
seonghobae Aug 3, 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
2 changes: 1 addition & 1 deletion .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '20'
node-version: '22.13.0'
cache: 'npm'

- name: Install dependencies
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/server-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node 22
- name: Setup Node 22.13
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22
node-version: 22.13.0
- name: Install
run: npm ci
- name: Unit tests (EVM · CPM · baseline · workload)
Expand All @@ -45,10 +45,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node 22
- name: Setup Node 22.13
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22
node-version: 22.13.0
- name: Install
run: npm ci
- name: Install Playwright (chromium)
Expand Down
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
workflows stay inherited from `ContextualWisdomLab/.github`, not copied
into this repository.

### Security

- Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or
unexpanded placeholder values so production deployments fail closed.
- Neutralized audit-log CSV formulas even when executable prefixes are hidden
behind leading whitespace.
- Replaced dynamic and lazy-regex MS Project XML block extraction with bounded
linear scans to prevent pathological backtracking on malformed imports.
- Rejected non-string password candidates at the authentication boundary.
- Added regression coverage that prevents array-valued passwords from being
coerced into valid credentials.
- Updated Hono runtime dependencies to patched supported releases.

### Changed

- 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다.
- 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다.
- `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다.
- Treat fields added only to an editor draft as unsaved changes so unload and
cancel safeguards cannot silently discard newly introduced data.
- Centralized OpenCode Review, Strix Security Scan, PR Review Merge
Scheduler, failed-check explanation, and coverage evidence ownership in
`ContextualWisdomLab/.github`, removing repository-local workflow,
Expand All @@ -46,4 +61,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [1.0.1] - 2026-06-25
### 성능 개선 (Performance)
- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다.
- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다.
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ CPM critical path, and a weekly Gantt overlay. Two modes:
# Standalone client (no install needed)
python3 -m http.server 4173 # open http://127.0.0.1:4173

# Cloud server (Node >= 22 — uses node:sqlite)
# Cloud server (Node ^22.13.0 || >=23.4.0 — uses node:sqlite; matches package.json engines)
npm install
npm run server # API + static client on :8787

Expand All @@ -40,7 +40,11 @@ python3 -m pytest tests/config # workflow-ownership / governance checks
node tests/unit/cpm.test.mjs
npx playwright test tests/e2e/scopeweave.spec.js

# Full stack via Docker (needs SCOPEWEAVE_JWT_SECRET in prod)
# Full stack via Docker (needs SCOPEWEAVE_JWT_SECRET — persist across restarts)
# Generate once and store outside git (e.g. shell profile / secrets manager).
# Re-running openssl each start mints a new key and invalidates existing JWTs.
: "${SCOPEWEAVE_JWT_SECRET:?Set a persistent ≥32-char secret before starting}"
# First-time only: export SCOPEWEAVE_JWT_SECRET="$(openssl rand -base64 32)"
docker compose up --build # Dockerfile.server → :8787
```

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.server
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ COPY index.html 404.html app.js cloud-sync.js analytics.js styles.css wbs.json .

ENV PORT=8787
ENV SCOPEWEAVE_DB=/data/scopeweave.db
# SCOPEWEAVE_JWT_SECRET MUST be supplied at runtime — the app warns on the dev default.
# SCOPEWEAVE_JWT_SECRET MUST be supplied at runtime; startup fails when it is weak or absent.

RUN mkdir -p /data && chown -R node:node /data
USER node
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,23 @@ Standalone:
python3 -m http.server 4173 # open http://127.0.0.1:4173
```

Cloud (Node ≥ 22):
Cloud (Node 22.13+ or 23.4+):

```bash
npm install
# Persist this across restarts (do not re-mint every boot — that invalidates JWTs).
export SCOPEWEAVE_JWT_SECRET="${SCOPEWEAVE_JWT_SECRET:-$(openssl rand -base64 32)}"
npm run server # serves the API + the static client on :8787
```

Docker: `docker compose up` (see `Dockerfile.server` / `docs/deploy.md`).
Docker: set a **persistent** `SCOPEWEAVE_JWT_SECRET` first, then run `docker compose up`
(see `Dockerfile.server` / `docs/deploy.md`).

### Environment

| Var | Purpose |
| --- | --- |
| `SCOPEWEAVE_JWT_SECRET` | **Required in prod** — JWT signing secret |
| `SCOPEWEAVE_JWT_SECRET` | **Required** — JWT signing secret (at least 32 non-whitespace characters; startup fails closed otherwise) |
| `SCOPEWEAVE_DB` | SQLite path (default `data.db`; `:memory:` for tests) |
| `PORT` | API port (default 8787) |
| `OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI` | Real SSO IdP (mock when unset) |
Expand Down
28 changes: 23 additions & 5 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,28 @@ function bindModalEvents() {
});
}

function editorHasUnsavedChanges() {
if (!state.editor.mode || !state.editor.draft || !state.editor.initialDraft) {
return false;
}
const draftKeys = new Set([
...Object.keys(state.editor.initialDraft),
...Object.keys(state.editor.draft),
]);
return Array.from(draftKeys).some(
(key) => state.editor.draft[key] !== state.editor.initialDraft[key]
);
}

function bindGlobalEvents() {
// Warn on tab close/refresh when the inline editor has a dirty draft.
// Browsers only show a generic leave-site dialog; returnValue is required.
window.addEventListener('beforeunload', (event) => {
if (!editorHasUnsavedChanges()) return;
event.preventDefault();
event.returnValue = '';
});

document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
if (!elements.ganttModal.classList.contains('hidden')) {
Expand Down Expand Up @@ -1196,11 +1217,8 @@ function openEditor({ mode, targetId = null, parentId = null, depth = 1, insertA
}

function closeEditor(force = false) {
if (!force && state.editor.draft && state.editor.initialDraft) {
const hasChanges = Object.keys(state.editor.initialDraft).some(
key => state.editor.draft[key] !== state.editor.initialDraft[key]
);
if (hasChanges && !window.confirm('저장하지 않은 변경 사항이 있습니다. 편집을 취소하시겠습니까?')) {
if (!force && editorHasUnsavedChanges()) {
if (!window.confirm('저장하지 않은 변경 사항이 있습니다. 편집을 취소하시겠습니까?')) {
return;
}
}
Expand Down
39 changes: 34 additions & 5 deletions cloud-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -739,25 +739,54 @@ function openReportModal() {
// no DOMParser needed → node-testable); swap for a real XML parser if
// hand-edited files ever matter.
export function parseMsProjectXml(xml) {
// Fully linear extract (indexOf/slice) — no dynamic RegExp and no lazy
// [\s\S]*? block collectors (those can quadratic-backtrack on truncated input).
const tag = (block, name) => {
const m = block.match(new RegExp(`<${name}>([^<]*)</${name}>`));
return m ? m[1].trim() : '';
const openingTag = `<${name}>`;
const closingTag = `</${name}>`;
const valueStart = block.indexOf(openingTag);
if (valueStart === -1) return '';
const contentStart = valueStart + openingTag.length;
const valueEnd = block.indexOf(closingTag, contentStart);
return valueEnd === -1 ? '' : block.slice(contentStart, valueEnd).trim();
};
const collectBlocks = (source, openTag, closeTag) => {
const out = [];
let from = 0;
for (;;) {
const start = source.indexOf(openTag, from);
if (start === -1) break;
const contentStart = start + openTag.length;
const end = source.indexOf(closeTag, contentStart);
// Incomplete open tag: stop linearly (do not rescan the remainder).
if (end === -1) break;
out.push(source.slice(start, end + closeTag.length));
from = end + closeTag.length;
}
return out;
};
const predecessorIds = (block) => {
const ids = [];
for (const link of collectBlocks(block, '<PredecessorLink>', '</PredecessorLink>')) {
const uid = tag(link, 'PredecessorUID');
if (/^\d+$/.test(uid)) ids.push(`msp-${uid}`);
}
return ids;
};
const unescape = (s) => s
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/&apos;/g, "'").replace(/&amp;/g, '&');
const day = (s) => (/^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : '');
const tasks = [];
const parents = {}; // depth -> last task id at that depth
const blocks = xml.match(/<Task>[\s\S]*?<\/Task>/g) || [];
const blocks = collectBlocks(String(xml || ''), '<Task>', '</Task>');
for (const block of blocks) {
const uid = tag(block, 'UID');
const name = unescape(tag(block, 'Name'));
if (!uid || uid === '0' || !name) continue; // project-summary row / blanks
const level = Math.max(1, Number(tag(block, 'OutlineLevel')) || 1);
const depth = Math.min(level, 3); // deeper levels flatten to task level
const preds = [...block.matchAll(/<PredecessorLink>[\s\S]*?<PredecessorUID>(\d+)<\/PredecessorUID>[\s\S]*?<\/PredecessorLink>/g)]
.map((m) => `msp-${m[1]}`);
const preds = predecessorIds(block);
const pct = Number(tag(block, 'PercentComplete')) || 0;
const t = {
id: `msp-${uid}`,
Expand Down
11 changes: 7 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Local / single-node full-stack ScopeWeave SaaS.
# Usage:
# export SCOPEWEAVE_JWT_SECRET=$(openssl rand -base64 32)
# # First-time only: generate a secret, then store it outside Git.
# # Restore the same value from a shell profile or secrets manager on every start.
# export SCOPEWEAVE_JWT_SECRET="$(openssl rand -base64 32)"
# : "${SCOPEWEAVE_JWT_SECRET:?Restore the persistent JWT secret before starting}"
# docker compose up --build
# Then open http://localhost:8787
services:
Expand All @@ -11,9 +14,9 @@ services:
ports:
- "8787:8787"
environment:
# Default keeps `docker compose config/build` runnable (CI evidence gate);
# the server logs a loud warning if this insecure default reaches runtime.
SCOPEWEAVE_JWT_SECRET: ${SCOPEWEAVE_JWT_SECRET:-insecure-dev-secret-CHANGE-ME}
# Required at runtime (≥32 non-whitespace chars). No insecure default —
# server/auth.mjs fails closed when unset or weak.
SCOPEWEAVE_JWT_SECRET: ${SCOPEWEAVE_JWT_SECRET:-}
volumes:
- scopeweave-data:/data
restart: unless-stopped
Expand Down
17 changes: 15 additions & 2 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,33 @@ one origin (so the browser's `default-src 'self'` CSP allows the API calls).

## Quick start (Docker Compose)

Create the signing key once in a user-only file outside the repository, then
reload that same key for every restart. Replacing it invalidates all existing
session JWTs.

```bash
export SCOPEWEAVE_JWT_SECRET=$(openssl rand -base64 32) # required
install -d -m 700 "$HOME/.config/scopeweave"
if [ ! -s "$HOME/.config/scopeweave/jwt-secret" ]; then
umask 077
openssl rand -base64 32 > "$HOME/.config/scopeweave/jwt-secret"
fi
export SCOPEWEAVE_JWT_SECRET="$(cat "$HOME/.config/scopeweave/jwt-secret")"
docker compose up --build
# open http://localhost:8787
```

For managed deployments, store the same value in the platform's secrets
manager instead of a local file. Rotate it only as an intentional global
session-revocation operation.

That builds `Dockerfile.server`, runs the Node backend as a non-root user, and
persists the database in the `scopeweave-data` volume.

## Required / optional environment

| Var | Required | Purpose |
| --- | --- | --- |
| `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Use a long random value. The app warns loudly if the dev default is used. |
| `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Startup fails unless it contains at least 32 non-whitespace characters. |
| `PORT` | no (default 8787) | Listen port |
| `SCOPEWEAVE_DB` | no (default `/data/scopeweave.db`) | SQLite file path (on the volume) |
| `SCOPEWEAVE_DEV` | no | Must be `1` to enable the dev `activate-pro` endpoint. **Never set in production.** |
Expand Down
33 changes: 33 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ScopeWeave security invariants

ScopeWeave treats the following controls as release-blocking invariants. A change that weakens one of these controls must include an explicit threat-model update and regression coverage.

## Authentication and signing keys

- `SCOPEWEAVE_JWT_SECRET` is mandatory at process startup.
- The secret must contain at least 32 non-whitespace characters and must not be an unexpanded environment placeholder.
- Production deployments must restore the same secret across restarts. Rotating it is an intentional operation because all existing JWTs become invalid.
- Password verification rejects non-string candidate values before hashing or comparison.

## Session revocation

Bearer-token middleware and every endpoint that accepts a JWT through another transport must compare the token's `tv` claim with the user's current database `token_version`.

## Spreadsheet exports

Every user-controlled CSV cell is neutralized when, after optional leading whitespace, it begins with `=`, `+`, `-`, `@`, or `|`. Export code must not rely on callers to sanitize values.

## XML imports

Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking.

## Release verification

Before merging security-sensitive changes, the current head must pass:

- unit and API tests;
- cloud UI end-to-end tests;
- property fuzzing;
- dependency and OSV review;
- Semgrep and repository security scans;
- required independent review gates, including coverage evidence.
Loading
Loading