diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..a9b40828 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,39 @@ +name: CodeQL + +on: + pull_request: + branches: ["develop"] + push: + branches: ["develop", "master"] + schedule: + - cron: "15 2 * * 6" + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + analyze: + name: Analyze (javascript-typescript) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: javascript-typescript + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + category: "/language:javascript-typescript" diff --git a/.github/workflows/osvscanner.yml b/.github/workflows/osvscanner.yml index 9c6baf6b..95abe0cf 100644 --- a/.github/workflows/osvscanner.yml +++ b/.github/workflows/osvscanner.yml @@ -12,12 +12,14 @@ name: OSV-Scanner 'on': + pull_request: + branches: ["develop"] + push: + branches: ["develop", "master"] schedule: - cron: '20 19 * * 5' permissions: - # Require writing security events to upload SARIF file to security tab - security-events: write # Read commit contents contents: read # Read PR metadata for comparison of introduced vulnerabilities @@ -28,6 +30,11 @@ permissions: jobs: scan: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + actions: read + security-events: write concurrency: # Avoid canceling this workflow due to other workflows' concurrency queues (e.g., CodeQL). # Key off PR number for PR/merge_group, otherwise ref. diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml index 9c482842..ed4b0195 100644 --- a/.github/workflows/scorecard-analysis.yml +++ b/.github/workflows/scorecard-analysis.yml @@ -1,8 +1,10 @@ name: Scorecard analysis on: - push: + pull_request: branches: ["develop"] + push: + branches: ["develop", "master"] schedule: - cron: "30 1 * * 6" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 8e17c9ae..b0614fd3 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -1,6 +1,9 @@ name: Trivy Security Scan on: + pull_request: + branches: + - develop push: branches: - develop diff --git a/Dockerfile b/Dockerfile index b57d1b66..bfdd45d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1.25-alpine +FROM nginx:1.25-alpine@sha256:516475cc129da42866742567714ddc681e5eed7b9ee0b9e9c015e464b4221a00 COPY infra/nginx/default.conf /etc/nginx/conf.d/default.conf COPY index.html 404.html app.js styles.css wbs.json /usr/share/nginx/html/ COPY docs/user-guide.md /usr/share/nginx/html/docs/ @@ -10,4 +10,5 @@ RUN touch /var/run/nginx.pid && \ USER nginx EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1 CMD ["nginx", "-g", "daemon off;"] diff --git a/app.js b/app.js index a3099f66..c9ddccdf 100644 --- a/app.js +++ b/app.js @@ -112,6 +112,27 @@ const EDITOR_FIELD_TEST_IDS = Object.freeze(Object.assign(Object.create(null), { })); const LEGACY_PLANNED_END_FIELD = 'plannedEnd' + 'Ddate'; +const TASK_STORAGE_FIELDS = Object.freeze([ + 'id', + 'parentId', + 'depth', + 'expanded', + 'pendingDelete', + 'isSynthetic', + 'phase', + 'activity', + 'task', + 'categoryLarge', + 'categoryMedium', + 'documentName', + 'owner', + 'supportTeam', + 'plannedStartDate', + 'plannedEndDate', + 'actualProgressStatus', + 'actualStartDate', + 'actualEndDate' +]); const DEFAULT_EDITOR_STATE = { mode: null, @@ -187,6 +208,7 @@ async function bootstrap() { const savedState = loadLocalState(); if (savedState) { hydrateState(savedState); + persistState(); } else { const seedData = await loadSeedTasks(); state.tasks = normalizeImportedTasks(seedData); @@ -479,6 +501,7 @@ function setTableBodyRows(rows) { function createEmptyStateRow() { const row = document.createElement('tr'); const cell = document.createElement('td'); + cell.className = 'empty-state-cell'; cell.colSpan = 21; const emptyState = document.createElement('div'); @@ -1420,11 +1443,10 @@ function findTask(taskId) { } function persistState() { - // ⚡ Bolt: Remove redundant O(N) object cloning before JSON.stringify to prevent massive memory allocations on every keystroke const payload = { projectName: state.projectName, baseDate: state.baseDate, - tasks: state.tasks + tasks: state.tasks.map(createPersistableTask) }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); @@ -1460,13 +1482,26 @@ function hydrateState(savedState) { function normalizeStoredTask(task) { const safeTask = isTaskRecord(task) ? task : {}; - const normalizedTask = { + return createPersistableTask({ ...safeTask, plannedEndDate: getPlannedEndDateValue(safeTask), expanded: safeTask.expanded !== false - }; - delete normalizedTask[LEGACY_PLANNED_END_FIELD]; - return normalizedTask; + }); +} + +function createPersistableTask(task) { + const safeTask = isTaskRecord(task) ? task : {}; + const persistableTask = Object.create(null); + for (const field of TASK_STORAGE_FIELDS) { + if (safeTask[field] !== undefined) { + persistableTask[field] = safeTask[field]; + } + } + persistableTask.plannedEndDate = getPlannedEndDateValue(safeTask); + persistableTask.expanded = safeTask.expanded !== false; + persistableTask.pendingDelete = Boolean(safeTask.pendingDelete); + persistableTask.isSynthetic = Boolean(safeTask.isSynthetic); + return persistableTask; } async function loadSeedTasks() { @@ -2483,6 +2518,9 @@ function debounce(callback, wait) { // Export for testing if (typeof window !== 'undefined') { window.validateDraft = validateDraft; + window.sanitizeCsvFormulaValue = sanitizeCsvFormulaValue; + window.csvEscape = csvEscape; + window.createTextCellContent = createTextCellContent; } bootstrap(); diff --git a/infra/k8s/deployment.yaml b/infra/k8s/deployment.yaml index 72ac3320..41978568 100644 --- a/infra/k8s/deployment.yaml +++ b/infra/k8s/deployment.yaml @@ -1,7 +1,15 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: scopeweave + labels: + app: scopeweave +--- apiVersion: apps/v1 kind: Deployment metadata: name: scopeweave-deployment + namespace: scopeweave labels: app: scopeweave spec: @@ -16,9 +24,10 @@ spec: spec: securityContext: runAsNonRoot: true - runAsUser: 101 - runAsGroup: 101 - fsGroup: 101 + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch seccompProfile: type: RuntimeDefault containers: @@ -26,6 +35,9 @@ spec: image: scopeweave:1.0.0 imagePullPolicy: IfNotPresent securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: @@ -72,6 +84,7 @@ apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: scopeweave-pdb + namespace: scopeweave spec: minAvailable: 1 selector: diff --git a/infra/k8s/service.yaml b/infra/k8s/service.yaml index d5db5d10..b11572cd 100644 --- a/infra/k8s/service.yaml +++ b/infra/k8s/service.yaml @@ -2,6 +2,7 @@ apiVersion: v1 kind: Service metadata: name: scopeweave-service + namespace: scopeweave spec: selector: app: scopeweave diff --git a/package-lock.json b/package-lock.json index f1805a3b..633a5075 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "scopeweave", "version": "1.0.0", "devDependencies": { - "@playwright/test": "^1.61.1" + "@playwright/test": "1.61.1", + "fast-check": "4.9.0" } }, "node_modules/@playwright/test": { @@ -27,6 +28,29 @@ "node": ">=18" } }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -73,6 +97,23 @@ "engines": { "node": ">=18" } + }, + "node_modules/pure-rand": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.1.tgz", + "integrity": "sha512-c58R2+SPFcSIPXoU834QN/KPDDOSd8sXcSrqf6e83Me6Rrp1EYkxukkjXMVrKvKaADs1SOyNkWdfvLf6zY8qLQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" } } } diff --git a/package.json b/package.json index b07a4679..76892778 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "description": "Production-grade pure HTML/CSS/JS WBS planner", "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "node scripts/ci/static_coverage_evidence.mjs coverage", + "coverage": "node scripts/ci/static_coverage_evidence.mjs coverage && npm run test:fuzz", "test:e2e": "playwright test", - "test:e2e:headed": "playwright test --headed" + "test:e2e:headed": "playwright test --headed", + "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js" }, "devDependencies": { - "@playwright/test": "^1.61.1" + "@playwright/test": "1.61.1", + "fast-check": "4.9.0" } } diff --git a/styles.css b/styles.css index f26c9768..6ab6eee0 100644 --- a/styles.css +++ b/styles.css @@ -55,6 +55,7 @@ button { } .app-shell { + --table-empty-page-gutter: 98px; /* 2 * 48px shell padding + 2px table-section border */ display: flex; min-height: 100vh; flex-direction: column; @@ -801,6 +802,14 @@ select[data-inline-progress]:focus { font-weight: 500; } +.empty-state-cell { + padding: 0 !important; +} + +.empty-state-cell > .table-empty { + width: min(100%, calc(100vw - var(--table-empty-page-gutter))); +} + @media (max-width: 1400px) { .meta-grid-primary, .meta-grid-secondary, @@ -817,6 +826,7 @@ select[data-inline-progress]:focus { @media (max-width: 800px) { .app-shell { + --table-empty-page-gutter: 34px; /* 2 * 16px shell padding + 2px table-section border */ padding: 16px; gap: 16px; } @@ -840,6 +850,10 @@ select[data-inline-progress]:focus { border-radius: var(--radius-sm); padding: 16px; } + + .empty-state-cell > .table-empty { + padding: 48px 24px; + } } @media (prefers-reduced-motion: reduce) { diff --git a/tests/config/test_strix_static_repo_adaptations.py b/tests/config/test_strix_static_repo_adaptations.py index 75ee487e..2df3ac34 100644 --- a/tests/config/test_strix_static_repo_adaptations.py +++ b/tests/config/test_strix_static_repo_adaptations.py @@ -40,8 +40,11 @@ def test_kubernetes_deployment_uses_non_root_versioned_runtime() -> None: assert 'image: scopeweave:latest' not in deployment_source assert 'image: scopeweave:1.0.0' in deployment_source + assert 'namespace: scopeweave' in deployment_source + assert 'namespace: scopeweave' in service_source assert 'runAsNonRoot: true' in deployment_source - assert 'runAsUser: 101' in deployment_source + assert 'runAsUser: 10001' in deployment_source + assert 'runAsGroup: 10001' in deployment_source assert 'allowPrivilegeEscalation: false' in deployment_source assert 'readOnlyRootFilesystem: true' in deployment_source assert 'drop:' in deployment_source @@ -54,10 +57,7 @@ def test_kubernetes_deployment_uses_non_root_versioned_runtime() -> None: def test_companion_workflows_cover_named_requirements_manifests_and_full_history() -> None: - dependency_review_source = DEPENDENCY_REVIEW_WORKFLOW.read_text(encoding="utf-8") osv_source = OSV_WORKFLOW.read_text(encoding="utf-8") - assert 'fetch-depth: 0' in dependency_review_source - assert 'dependency_graph?.status || \'unknown\'' in dependency_review_source - assert 'requirements(-[A-Za-z0-9._-]+)?\\.txt' in dependency_review_source + assert not DEPENDENCY_REVIEW_WORKFLOW.exists() assert 'requirements(-[A-Za-z0-9._-]+)?\\.txt' in osv_source diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js new file mode 100644 index 00000000..2ee9f1bb --- /dev/null +++ b/tests/e2e/csv_formula_fuzz.spec.js @@ -0,0 +1,29 @@ +const { test, expect } = require('@playwright/test'); +const fc = require('fast-check'); + +test.describe('CSV formula fuzzing', () => { + test.beforeEach(async ({ page }) => { + await page.goto('./'); + }); + + test('neutralizes spreadsheet formula prefixes before CSV escaping', async ({ page }) => { + await fc.assert( + fc.asyncProperty(fc.string({ maxLength: 128 }), async (candidate) => { + const result = await page.evaluate((value) => ({ + escaped: window.csvEscape(value), + sanitized: window.sanitizeCsvFormulaValue(value) + }), candidate); + const normalized = String(candidate ?? ''); + const dangerous = /^\s*[=+\-@|]/.test(normalized); + const expectedSanitized = dangerous ? `'${normalized}` : normalized; + + expect(result.sanitized).toBe(expectedSanitized); + expect(result.escaped.startsWith('"')).toBe(true); + expect(result.escaped.endsWith('"')).toBe(true); + expect(result.escaped.slice(1, -1).replace(/""/g, '"')).toBe(expectedSanitized); + expect(/^\s*[=+\-@|]/.test(result.sanitized)).toBe(false); + }), + { numRuns: 100, seed: 20260709 } + ); + }); +}); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 664f2888..1b498f2a 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -12,6 +12,13 @@ const addTopLevelTask = async (page, values) => { await page.getByRole('button', { name: '저장', exact: true }).click(); }; +const expectSaveBlockedWith = async (page, message) => { + const saveButton = page.getByRole('button', { name: '저장', exact: true }); + await expect(saveButton).toBeDisabled(); + await expect(page.locator('#editor-errors')).toContainText(message); + await expect(page.locator('.editor-panel')).toBeVisible(); +}; + const readHierarchySnapshot = async (page) => page.locator('tbody tr[data-task-id]').evaluateAll((rows) => rows.map((row) => ({ phase: row.children[1]?.innerText.replace(/-\s*값 없음/g, '-').trim() || '', activity: row.children[2]?.innerText.replace(/-\s*값 없음/g, '-').trim() || '', @@ -109,6 +116,7 @@ test.describe('ScopeWeave Planner', () => { await page.setViewportSize({ width: 375, height: 667 }); await page.goto('./'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); const mobileColumns = await page.evaluate(() => { const isVisible = (element) => getComputedStyle(element).display !== 'none'; const headers = Array.from(document.querySelectorAll('thead th')) @@ -171,6 +179,37 @@ test.describe('ScopeWeave Planner', () => { await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('title', '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'); }); + test('keeps the empty WBS state inside the mobile table viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.evaluate(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Empty Scope', + baseDate: '2026-04-20', + tasks: [] + })); + }); + await page.reload(); + + await expect(page.locator('.empty-state-cell > .table-empty')).toBeVisible(); + const layout = await page.evaluate(() => { + const scrollRect = document.querySelector('.table-scroll').getBoundingClientRect(); + const emptyRect = document.querySelector('.empty-state-cell > .table-empty').getBoundingClientRect(); + const actionsRect = document.querySelector('.empty-state-cell .empty-actions').getBoundingClientRect(); + + return { + scrollLeft: scrollRect.left, + scrollRight: scrollRect.right, + emptyLeft: emptyRect.left, + emptyRight: emptyRect.right, + actionsRight: actionsRect.right + }; + }); + + expect(layout.emptyLeft).toBeGreaterThanOrEqual(layout.scrollLeft - 1); + expect(layout.emptyRight).toBeLessThanOrEqual(layout.scrollRight + 1); + expect(layout.actionsRight).toBeLessThanOrEqual(layout.scrollRight + 1); + }); + test('adds a top-level task and restores it after reload', async ({ page }) => { const phaseName = 'P1000.분석단계'; @@ -207,7 +246,7 @@ test.describe('ScopeWeave Planner', () => { await expect(leafRow).toHaveCount(1); const leafAddChildButton = leafRow.getByRole('button', { name: '하위 추가' }); await expect(leafAddChildButton).toHaveAttribute('aria-disabled', 'true'); - await leafAddChildButton.click(); + await leafAddChildButton.dispatchEvent('click'); await expect(page.locator('#toast')).toContainText('최대 3단계까지만 추가할 수 있습니다.'); await expect(page.locator('.editor-panel')).toHaveCount(0); }); @@ -247,12 +286,12 @@ test.describe('ScopeWeave Planner', () => { await expect(page.getByRole('dialog', { name: '간트 차트' })).toBeVisible(); const closeButton = page.getByRole('button', { name: '간트 차트 닫기' }); - const firstBar = page.locator('.gantt-bar.plan').first(); - await expect(firstBar).toBeVisible(); + const wrappedBar = page.locator('.gantt-bar.plan[aria-label*="포커스 순환 검증"]').first(); + await expect(wrappedBar).toBeVisible(); await closeButton.focus(); await page.keyboard.press('Shift+Tab'); - await expect(firstBar).toBeFocused(); + await expect(wrappedBar).toBeFocused(); await page.keyboard.press('Tab'); await expect(closeButton).toBeFocused(); @@ -265,13 +304,26 @@ test.describe('ScopeWeave Planner', () => { await dialog.dismiss(); }); - await addTopLevelTask(page, { - phase: '', - categoryLarge: '간트검증', - owner: '담당자A', - plannedStartDate: '2026-05-18', - plannedEndDate: '2026-05-20' + await page.evaluate(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Legacy XSS Project', + baseDate: '2026-05-18', + tasks: [{ + id: 'legacy-xss-task', + parentId: null, + depth: 1, + expanded: true, + phase: '', + categoryLarge: '간트검증', + owner: '담당자A', + plannedStartDate: '2026-05-18', + plannedEndDate: '2026-05-20', + actualProgressStatus: '미착수(0%)', + isSynthetic: false + }] + })); }); + await page.reload(); await page.getByRole('button', { name: '간트차트보기' }).click(); await expect(page.getByRole('dialog', { name: '간트 차트' })).toBeVisible(); @@ -696,28 +748,19 @@ test.describe('ScopeWeave Planner', () => { test('rejects saving a top-level task with HTML tags in the phase field', async ({ page }) => { await page.getByRole('button', { name: '최상위 작업 추가' }).click(); await page.locator('[data-testid="editor-phase"]').fill('Test Phase