diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..b401410 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,105 @@ +name: Dependency audit + +on: + push: + branches: [main] + paths: + - "package.json" + - "pnpm-lock.yaml" + - "Cargo.toml" + - "Cargo.lock" + - "apps/desktop/src-tauri/Cargo.toml" + - "engines/drawscope-engine/pyproject.toml" + - "engines/drawscope-engine/uv.lock" + - ".github/workflows/dependency-audit.yml" + pull_request: + paths: + - "package.json" + - "pnpm-lock.yaml" + - "Cargo.toml" + - "Cargo.lock" + - "apps/desktop/src-tauri/Cargo.toml" + - "engines/drawscope-engine/pyproject.toml" + - "engines/drawscope-engine/uv.lock" + - ".github/workflows/dependency-audit.yml" + schedule: + - cron: "43 8 3 * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dependency-audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + javascript: + name: JavaScript lockfile + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + version: 9.15.0 + run_install: false + + - name: Audit the complete JavaScript lockfile + run: pnpm audit --audit-level high + + python: + name: Python lockfile + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.30" + enable-cache: true + + - name: Export the frozen Python dependency graph + run: >- + uv export --project engines/drawscope-engine --frozen --all-groups + --no-emit-project --format requirements.txt + --output-file "${RUNNER_TEMP}/drawscope-audit-requirements.txt" --quiet + + - name: Audit the exported Python lock graph + run: >- + uvx --from pip-audit==2.10.1 pip-audit + --requirement "${RUNNER_TEMP}/drawscope-audit-requirements.txt" + --strict --progress-spinner off + + rust: + name: Rust lockfile + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install cargo-audit + run: cargo install cargo-audit --locked --version 0.22.2 + + - name: Audit the complete Rust lockfile + run: cargo audit --file Cargo.lock diff --git a/DEPENDENCY_POLICY.md b/DEPENDENCY_POLICY.md index 877eb44..ae77f89 100644 --- a/DEPENDENCY_POLICY.md +++ b/DEPENDENCY_POLICY.md @@ -5,3 +5,5 @@ Dependabot groups compatible minor and patch updates by ecosystem. Every group m Automated version-update pull requests intentionally exclude semantic-major upgrades. Major upgrades are reviewed deliberately against migration notes, data compatibility, release behavior, and rollback plans. Security updates remain enabled and are evaluated independently of this cadence. GitHub Actions are pinned to immutable commit identities with their release tag noted in comments. Dependabot may propose monthly action updates; maintainers verify the upstream repository and release notes before merge. + +The dedicated dependency-audit workflow covers the complete pnpm lockfile, the frozen uv runtime-and-development graph, and the complete Cargo lockfile. Known vulnerabilities fail their language-specific job. RustSec informational warnings are reviewed in full and may remain only when target reachability and the lack of a compatible fix are documented; they are never hidden behind an unrecorded ignore. See [`docs/DEPENDENCY-AUDIT.md`](docs/DEPENDENCY-AUDIT.md) for the reproducible commands and dated evidence. diff --git a/README.md b/README.md index fbf6fac..89885b5 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@

CI status CodeQL status + Dependency audit status Latest release - Archive snapshot 2026-07-28 + Dated archive snapshot 2026-07-28 Windows x64 Local first MIT license @@ -104,14 +105,16 @@ The Rust layer owns persistence, validation, migrations, file boundaries, and si Snapshot date: **2026-07-28** · Latest captured draw: **2026-07-28** · Known gaps: **4** -| Game | Coverage | Draws | Sessions | -| --- | ---: | ---: | ---: | -| Powerball | 1992-04-22 → 2026-07-27 | 3,813 | 1 | -| Mega Millions | 2002-05-17 → 2026-07-24 | 2,522 | 1 | -| Illinois Lotto | 2014-01-20 → 2026-07-27 | 1,960 | 1 | -| Lucky Day Lotto | 2014-01-19 → 2026-07-28 | 9,147 | 2 | -| Pick 3 | 2010-01-01 → 2026-07-28 | 12,078 | 2 | -| Pick 4 | 2010-01-01 → 2026-07-28 | 12,078 | 2 | +This is a dated offline evidence snapshot—not live lottery data. The [weekly freshness workflow](.github/workflows/archive-freshness.yml) flags a refresh as due after 14 days and stale after 30; it never invents missing rows or substitutes an unreviewed source. + +**Coverage by game** + +- **Powerball:** 1992-04-22 → 2026-07-27 · 3,813 draws · 1 session +- **Mega Millions:** 2002-05-17 → 2026-07-24 · 2,522 draws · 1 session +- **Illinois Lotto:** 2014-01-20 → 2026-07-27 · 1,960 draws · 1 session +- **Lucky Day Lotto:** 2014-01-19 → 2026-07-28 · 9,147 draws · 2 sessions +- **Pick 3:** 2010-01-01 → 2026-07-28 · 12,078 draws · 2 sessions +- **Pick 4:** 2010-01-01 → 2026-07-28 · 12,078 draws · 2 sessions Two isolated frozen-source rebuilds produced the same 41,394,176-byte SQLite database: @@ -174,7 +177,7 @@ pnpm dev - **Understand the product:** [documentation hub](docs/README.md), [responsible use](docs/RESPONSIBLE-USE.md), [known limitations](docs/KNOWN-LIMITATIONS.md) - **Review the research:** [worked case study](docs/CASE-STUDY.md), [methodology](docs/METHODOLOGY.md), [packaged-run evidence](examples/powerball-retrospective-v0.6.5/README.md), [source research](docs/SOURCE-RESEARCH.md), [database reconstruction](docs/DATABASE.md) - **Review the engineering:** [architecture](docs/ARCHITECTURE.md), [contracts](docs/CONTRACTS.md), [testing](docs/TESTING.md), [function inventory](docs/FUNCTION-INVENTORY.md) -- **Operate or assess risk:** [runbooks](docs/RUNBOOKS.md), [distribution/signing](docs/DISTRIBUTION.md), [maintenance policy](docs/MAINTENANCE.md), [security model](docs/SECURITY.md), [dependency policy](DEPENDENCY_POLICY.md), [accessibility](docs/ACCESSIBILITY.md) +- **Operate or assess risk:** [runbooks](docs/RUNBOOKS.md), [distribution/signing](docs/DISTRIBUTION.md), [maintenance policy](docs/MAINTENANCE.md), [security model](docs/SECURITY.md), [dependency-audit evidence](docs/DEPENDENCY-AUDIT.md), [dependency policy](DEPENDENCY_POLICY.md), [accessibility](docs/ACCESSIBILITY.md) ## Contributing and support diff --git a/docs/DEPENDENCY-AUDIT.md b/docs/DEPENDENCY-AUDIT.md new file mode 100644 index 0000000..b06e40c --- /dev/null +++ b/docs/DEPENDENCY-AUDIT.md @@ -0,0 +1,27 @@ +# Dependency-audit evidence + +DrawScope keeps three independently locked dependency graphs because its shipped desktop path crosses JavaScript/TypeScript, Rust, and Python. The dedicated [`Dependency audit`](../.github/workflows/dependency-audit.yml) workflow checks all three graphs on relevant pull requests and pushes, monthly, and on demand. It has read-only repository permissions and uses immutable GitHub Action revisions. + +## Reproduce the checks + +From the repository root: + +```powershell +pnpm audit --audit-level high + +$auditRequirements = Join-Path $env:TEMP "drawscope-audit-requirements.txt" +uv export --project engines/drawscope-engine --frozen --all-groups ` + --no-emit-project --format requirements.txt --output-file $auditRequirements --quiet +uvx --from pip-audit==2.10.1 pip-audit ` + --requirement $auditRequirements --strict --progress-spinner off + +cargo audit --file Cargo.lock +``` + +The JavaScript check includes development tooling because build and test dependencies can still affect CI and release integrity. The Python check exports the complete frozen runtime-and-development graph from `uv.lock`; it excludes only the local `drawscope-engine` package itself. The Rust check rejects known vulnerabilities while reporting informational maintenance and soundness advisories for review. + +## Current reviewed result + +On 2026-08-25, all three commands reported **no known vulnerabilities** in their locked graphs. `cargo audit 0.22.2` also reported 17 informational warnings inherited through Tauri: twelve affect GTK3 or its macro chain and are absent from the compiled Windows target, while five mark legacy Unicode crates in Tauri's `urlpattern` chain as unmaintained. The Unicode crates are reachable during the Windows build, but RustSec does not identify an exploitable vulnerability or a compatible patched line. The warnings remain visible in audit output and in [known limitations](KNOWN-LIMITATIONS.md); they are not suppressed or described as fixed. + +This dated result is evidence for one lockfile state, not a permanent security claim. Dependabot, CodeQL, monthly audit runs, release review, and the private vulnerability-reporting channel remain separate controls. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index 35d19df..ec06bfe 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -69,6 +69,11 @@ viewport, and reduced-motion preference. ## Dependency maintenance +The dedicated [`Dependency audit`](../.github/workflows/dependency-audit.yml) workflow +checks the complete pnpm, uv, and Cargo lock graphs on relevant changes, monthly, and on +demand. Its exact commands, scope, and current dated result are recorded in +[`DEPENDENCY-AUDIT.md`](DEPENDENCY-AUDIT.md). + Dependabot updates are reviewed as normal code changes. Combine superseded patch-level updates when the lockfile can be regenerated and verified once; do not merge competing lockfiles independently. Follow [`DEPENDENCY_POLICY.md`](../DEPENDENCY_POLICY.md) for diff --git a/docs/README.md b/docs/README.md index b92095b..b4d296b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,7 @@ flowchart TB - [`RUNBOOKS.md`](RUNBOOKS.md) — build, release, recovery, and incident procedures - [`DISTRIBUTION.md`](DISTRIBUTION.md) — portable/installer packaging, signing, and publication gates - [`MAINTENANCE.md`](MAINTENANCE.md) — freshness, dependency, presentation, and release cadence +- [`DEPENDENCY-AUDIT.md`](DEPENDENCY-AUDIT.md) — reproducible JavaScript, Python, and Rust lock-graph checks - [`STANDARDS-COMPLIANCE.md`](STANDARDS-COMPLIANCE.md) — applicable standards and compliance posture ### Historical audit trail diff --git a/scripts/archive-presentation.mjs b/scripts/archive-presentation.mjs new file mode 100644 index 0000000..2d01550 --- /dev/null +++ b/scripts/archive-presentation.mjs @@ -0,0 +1,32 @@ +export function formatInteger(value) { + return new Intl.NumberFormat("en-US").format(value); +} + +export function renderArchiveCoverage(data) { + return data.games + .map( + (game) => + `- **${game.name}:** ${game.firstDraw} → ${game.lastDraw} · ${formatInteger(game.drawCount)} draws · ${game.sessions} ${game.sessions === 1 ? "session" : "sessions"}`, + ) + .join("\n"); +} + +export function renderArchiveSummary(data) { + return ` +## Verified archive snapshot + +Snapshot date: **${data.snapshotDate}** · Latest captured draw: **${data.latestDraw}** · Known gaps: **${data.knownGapCount}** + +This is a dated offline evidence snapshot—not live lottery data. The [weekly freshness workflow](.github/workflows/archive-freshness.yml) flags a refresh as due after 14 days and stale after 30; it never invents missing rows or substitutes an unreviewed source. + +**Coverage by game** + +${renderArchiveCoverage(data)} + +Two isolated frozen-source rebuilds produced the same ${data.databaseBytesLabel}-byte SQLite database: + +\`\`\`text +SHA-256 ${data.databaseSha256} +\`\`\` +`; +} diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index 7af863a..b455e1d 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -89,6 +89,7 @@ await Promise.all([ writeFile(path.join(outputRoot, "index.html"), html, "utf8"), copyFile(path.join(siteRoot, "styles.css"), path.join(outputRoot, "styles.css")), copyFile(path.join(siteRoot, "app.js"), path.join(outputRoot, "app.js")), + copyFile(path.join(siteRoot, "freshness.mjs"), path.join(outputRoot, "freshness.mjs")), copyFile(path.join(siteRoot, "favicon.svg"), path.join(outputRoot, "favicon.svg")), writeFile(path.join(outputRoot, ".nojekyll"), "", "utf8"), writeFile( diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs index 3266695..cbcf061 100644 --- a/scripts/check-documentation.mjs +++ b/scripts/check-documentation.mjs @@ -297,6 +297,7 @@ async function checkProjectSite() { for (const relative of [ "styles.css", "app.js", + "freshness.mjs", "favicon.svg", ".nojekyll", "robots.txt", diff --git a/scripts/presentation-data.mjs b/scripts/presentation-data.mjs index 0abe996..ea31b92 100644 --- a/scripts/presentation-data.mjs +++ b/scripts/presentation-data.mjs @@ -2,6 +2,10 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; +import { formatInteger } from "./archive-presentation.mjs"; + +export { renderArchiveCoverage, renderArchiveSummary } from "./archive-presentation.mjs"; + export const root = fileURLToPath(new URL("..", import.meta.url)); const gameOrder = ["powerball", "mega-millions", "lotto", "lucky-day-lotto", "pick-3", "pick-4"]; @@ -15,10 +19,6 @@ const presentationNames = { "pick-4": "Pick 4", }; -function formatInteger(value) { - return new Intl.NumberFormat("en-US").format(value); -} - export async function loadPresentationData() { const [version, manifestSource, catalogSource, sourceCatalogSource] = await Promise.all([ readFile(path.join(root, "VERSION"), "utf8"), @@ -70,32 +70,6 @@ export async function loadPresentationData() { }; } -export function renderArchiveTable(data) { - const rows = data.games - .map( - (game) => - `| ${game.name} | ${game.firstDraw} → ${game.lastDraw} | ${formatInteger(game.drawCount)} | ${game.sessions} |`, - ) - .join("\n"); - return `| Game | Coverage | Draws | Sessions |\n| --- | ---: | ---: | ---: |\n${rows}`; -} - -export function renderArchiveSummary(data) { - return ` -## Verified archive snapshot - -Snapshot date: **${data.snapshotDate}** · Latest captured draw: **${data.latestDraw}** · Known gaps: **${data.knownGapCount}** - -${renderArchiveTable(data)} - -Two isolated frozen-source rebuilds produced the same ${data.databaseBytesLabel}-byte SQLite database: - -\`\`\`text -SHA-256 ${data.databaseSha256} -\`\`\` -`; -} - export function replaceMarkedBlock(source, name, replacement) { const start = ``; const end = ``; diff --git a/scripts/presentation-regressions.test.ts b/scripts/presentation-regressions.test.ts index 24a1d17..20b1723 100644 --- a/scripts/presentation-regressions.test.ts +++ b/scripts/presentation-regressions.test.ts @@ -2,6 +2,8 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { describeSnapshotFreshness } from "../site/freshness.mjs"; +import { renderArchiveSummary } from "./archive-presentation.mjs"; import { documentLinksToExactUrl } from "./document-links.mjs"; const root = process.cwd(); @@ -23,6 +25,50 @@ describe("canonical project-site links", () => { }); }); +describe("dated archive presentation", () => { + const snapshot = "2026-07-28"; + + it.each([ + ["2026-08-11T00:00:00Z", 14, "current", "Current · 14 days old"], + ["2026-08-12T00:00:00Z", 15, "refresh-due", "Refresh due · 15 days old"], + ["2026-08-27T00:00:00Z", 30, "refresh-due", "Refresh due · 30 days old"], + ["2026-08-28T00:00:00Z", 31, "stale", "Stale · 31 days old"], + ])("labels the %s threshold honestly", (now, ageDays, state, label) => { + expect(describeSnapshotFreshness(snapshot, Date.parse(now))).toEqual({ + ageDays, + state, + label, + }); + }); + + it("rejects malformed dates instead of displaying a misleading age", () => { + expect(() => describeSnapshotFreshness("07/28/2026")).toThrow(/Invalid archive snapshot/); + }); + + it("renders archive coverage as a narrow-screen list instead of a wide table", () => { + const summary = renderArchiveSummary({ + snapshotDate: snapshot, + latestDraw: snapshot, + knownGapCount: 0, + databaseBytesLabel: "1,024", + databaseSha256: "a".repeat(64), + games: [ + { + name: "Powerball", + firstDraw: "1992-04-22", + lastDraw: "2026-07-27", + drawCount: 3813, + sessions: 1, + }, + ], + }); + + expect(summary).toContain("**Coverage by game**"); + expect(summary).toContain("- **Powerball:** 1992-04-22 → 2026-07-27 · 3,813 draws · 1 session"); + expect(summary).not.toContain("| Game | Coverage |"); + }); +}); + describe("project-site accessibility contracts", () => { it("keeps definition-list metric content inside term and description elements", async () => { const source = await readFile(path.join(root, "site", "index.template.html"), "utf8"); diff --git a/scripts/sync-presentation.mjs b/scripts/sync-presentation.mjs index e375128..e5582a1 100644 --- a/scripts/sync-presentation.mjs +++ b/scripts/sync-presentation.mjs @@ -45,8 +45,8 @@ for (const claim of requiredClaims) { if (!expected.includes(claim)) failures.push(`README.md is missing generated claim: ${claim}`); } for (const game of data.games) { - const row = `| ${game.name} | ${game.firstDraw} → ${game.lastDraw} |`; - if (!expected.includes(row)) + const coverage = `- **${game.name}:** ${game.firstDraw} → ${game.lastDraw} · ${game.drawCount.toLocaleString("en-US")} draws`; + if (!expected.includes(coverage)) failures.push(`README.md is missing current coverage for ${game.name}.`); } diff --git a/site/app.js b/site/app.js index b774177..a3f7249 100644 --- a/site/app.js +++ b/site/app.js @@ -1,3 +1,5 @@ +import { describeSnapshotFreshness } from "./freshness.mjs"; + const tourItems = [ { image: "assets/drawscope-overview.jpg", @@ -77,11 +79,12 @@ const snapshot = document.body.dataset.snapshotDate; const freshness = document.querySelector("[data-freshness]"); const freshnessLabel = document.querySelector("[data-freshness-label]"); if (snapshot && freshness && freshnessLabel) { - const snapshotTime = new Date(`${snapshot}T00:00:00Z`).getTime(); - const ageDays = Math.max(0, Math.floor((Date.now() - snapshotTime) / 86_400_000)); - freshnessLabel.textContent = `${ageDays} day${ageDays === 1 ? "" : "s"} old`; - freshness.classList.toggle("current", ageDays <= 14); - freshness.classList.toggle("stale", ageDays > 30); + const status = describeSnapshotFreshness(snapshot); + freshnessLabel.textContent = status.label; + freshness.dataset.state = status.state; + freshness.classList.toggle("current", status.state === "current"); + freshness.classList.toggle("refresh-due", status.state === "refresh-due"); + freshness.classList.toggle("stale", status.state === "stale"); } selectTour(0); diff --git a/site/freshness.mjs b/site/freshness.mjs new file mode 100644 index 0000000..c9486c8 --- /dev/null +++ b/site/freshness.mjs @@ -0,0 +1,25 @@ +const DAY_IN_MILLISECONDS = 86_400_000; + +function dayLabel(ageDays) { + return `${ageDays} day${ageDays === 1 ? "" : "s"} old`; +} + +export function describeSnapshotFreshness(snapshotDate, now = Date.now()) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(snapshotDate)) { + throw new Error(`Invalid archive snapshot date: ${snapshotDate}`); + } + + const snapshotTime = Date.parse(`${snapshotDate}T00:00:00Z`); + if (!Number.isFinite(snapshotTime)) { + throw new Error(`Invalid archive snapshot date: ${snapshotDate}`); + } + + const ageDays = Math.max(0, Math.floor((now - snapshotTime) / DAY_IN_MILLISECONDS)); + if (ageDays <= 14) { + return { ageDays, state: "current", label: `Current · ${dayLabel(ageDays)}` }; + } + if (ageDays <= 30) { + return { ageDays, state: "refresh-due", label: `Refresh due · ${dayLabel(ageDays)}` }; + } + return { ageDays, state: "stale", label: `Stale · ${dayLabel(ageDays)}` }; +} diff --git a/site/index.template.html b/site/index.template.html index 0661166..43b8402 100644 --- a/site/index.template.html +++ b/site/index.template.html @@ -261,6 +261,6 @@

Explore the record on your own machine.

Security - + diff --git a/site/styles.css b/site/styles.css index 8329ac5..15047e5 100644 --- a/site/styles.css +++ b/site/styles.css @@ -357,6 +357,9 @@ h3 { .freshness.current strong { color: var(--green); } +.freshness.refresh-due strong { + color: var(--amber); +} .freshness.stale strong { color: var(--danger); } @@ -369,6 +372,9 @@ h3 { .freshness.current .status-dot { background: var(--green); } +.freshness.refresh-due .status-dot { + background: var(--amber); +} .freshness.stale .status-dot { background: var(--danger); }