diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..cb05f99 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,15 @@ +name: "CodeQL Configuration for StableRoute Backend" + +paths: + - src + +paths-ignore: + - src/**/__tests__/** + - "**/node_modules/**" + - "**/dist/**" + - "**/coverage/**" + +packs: + - codeql/javascript-typescript: + - codeql/javascript-queries + - codeql/javascript-experimental diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..faa0b2a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,69 @@ +name: "CodeQL" + +on: + push: + branches: [main] + paths: + - "src/**" + - ".github/workflows/codeql.yml" + - ".github/codeql/**" + pull_request: + branches: [main] + paths: + - "src/**" + - ".github/workflows/codeql.yml" + - ".github/codeql/**" + schedule: + - cron: "37 4 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + language: ["javascript-typescript"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml + queries: security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" + upload: true + output: sarif-results + + - name: Upload SARIF results + uses: actions/upload-artifact@v4 + with: + name: codeql-sarif-${{ matrix.language }} + path: sarif-results + if-no-files-found: warn + retention-days: 30 diff --git a/docs/CI.md b/docs/CI.md index 9aee7ae..8db1f90 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -1,6 +1,8 @@ # CI pipeline -GitHub Actions workflow `.github/workflows/ci.yml` runs on every push/PR to `main`: +GitHub Actions runs two workflows on every push/PR to `main`: + +## 1. Build & Test (`.github/workflows/ci.yml`) 1. **validate-openapi** — `swagger-cli validate openapi.yaml` 2. **build-test** — `npm ci`, `npm run lint`, `npm run build`, `npm test`, `npm run test:coverage` @@ -14,3 +16,160 @@ Upload the `coverage/` artifact from CI when debugging threshold failures locall ```bash npm run test:coverage ``` + +--- + +## 2. CodeQL Static Analysis (`.github/workflows/codeql.yml`) + +CodeQL performs inter-procedural data-flow and taint analysis on the TypeScript sources to catch injection, XSS, unsafe deserialization, and hardcoded-secret bugs before they reach production. + +### Trigger conditions + +| Event | When it runs | Path filter | +| --------------- | ---------------------------------------------- | ---------------------------------------- | +| `push` | Commits to `main` | `src/**`, `.github/workflows/codeql.yml`, `.github/codeql/**` | +| `pull_request` | PRs targeting `main` | same as push | +| `schedule` | Weekly, Monday 04:37 UTC (off-peak) | full repo scan | + +### Analyzed scope + +Defined in `.github/codeql/codeql-config.yml`: + +- **Include:** `src/` (all production TypeScript, including the 2251-line `src/index.ts` routing engine) +- **Exclude:** `src/**/__tests__/**`, `**/node_modules/**`, `**/dist/**`, `**/coverage/**` + +### Query packs + +The workflow runs `queries: security-and-quality` against the `codeql/javascript-typescript` pack: + +- `codeql/javascript-queries` — default security queries (SQL/NoSQL injection, command injection, path traversal, prototype pollution, hardcoded secrets, etc.) +- `codeql/javascript-experimental` — newer taint- and data-flow queries that may surface additional true positives in Express/Pino/Helmet-heavy codebases. + +### Job permissions + +The `analyze` job requests the minimum permissions required: + +| Permission | Level | Why it is needed | +| ------------------- | ------- | ------------------------------------------------------ | +| `security-events` | `write` | Upload SARIF results to the GitHub Security tab | +| `actions` | `read` | Read action artifacts across job dependencies | +| `contents` | `read` | Clone repository sources for analysis | + +### Artifacts + +- **`codeql-sarif-javascript-typescript`** — the raw SARIF JSON, retained for 30 days. Download this artifact to reproduce findings locally with the CodeQL CLI. + +--- + +## Triage workflow — CodeQL findings + +When a CodeQL job reports `new alert(s) found`, follow this process. + +### 1. Open the Security tab + +1. Go to the repository home page. +2. Click **Security** → **Code scanning**. +3. Filter by **Tool = CodeQL** and **Branch = **. + +Each alert card shows: +- The rule id (e.g. `js/sql-injection`) and CWE (e.g. CWE-89). +- A data-flow path graph with source → step(s) → sink. +- The exact commit that introduced the alert (for `push`-triggered runs). + +### 2. Classify the finding + +| Outcome | How to decide | +| ------------------ | -------------------------------------------------------------------------------------------------------------------- | +| **True positive** | The sink is reachable with attacker-controlled data, and the code does not validate/encode adequately before use. | +| **False positive** | The data path is impossible (impossible enum branches, sanitizer present but not modelled, constant only, etc.). | +| **Won't fix** | Finding is in dead code, behind an admin-only boundary, or the risk is accepted per the SECURITY.md threat model. | + +### 3. Fix or dismiss + +#### Fix a true positive + +1. Add an input-validation layer (Zod schema, Joi, or the project's existing `parseAmountAssetCode`-style pure validators — see `src/utils/*`). +2. Parameterize queries / escape output at the sink layer following the rule remediation hint. +3. Add a unit test under `src/__tests__/` covering both the benign and malicious payloads. Coverage threshold is 95 % for impacted modules. +4. Re-run the **CodeQL** workflow from the **Actions** tab. The alert status moves to **Fixed** when the scan no longer reaches the sink. + +#### Dismiss a false positive / won't fix + +Dismissal is performed inside the alert card in the UI. Choose a **Reason**: + +| Reason | When to use | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **False positive** | CodeQL missed a sanitizer or structural impossibility. | +| **Used in tests** | Alert is inside `src/**/__tests__/**` and somehow leaked through path filters. | +| **Not applicable** | The vulnerable API surface is not exposed to untrusted actors in this deployment model. | +| **Risk accepted** | The maintainers acknowledge the risk and recorded an exception in `SECURITY.md`. | + +**Required:** always fill the **Comment** field with a one-line justification linking to the specific sanitizer / code path. Example: + +> Tainted value passes through `parseAmountAssetCode()` strict regex validator at src/utils/clientIp.ts#L42 before reaching the sink. + +### 4. Suppression patterns (prefer dismissal in UI first) + +If a finding recurs frequently across multiple locations *and* the CodeQL model cannot be improved with a `codeql-pack-filter`, add a **comment-based suppression** directly above the offending line, following the style: + +```ts +// lgtm[js/unused-local-variable] +// codeql[js/prototype-pollution] false positive: proto guarded by Object.hasOwn +const result = map[key]; +``` + +**Rule of thumb:** prefer UI dismissal. Code comment suppression is only for patterns that the security team has explicitly blessed after review. + +### 5. Tracking open findings + +- Open alerts appear in the **Security** tab with **Open** status. +- A weekly digest is sent to repository watchers following the Monday scheduled scan. +- Any alert open longer than 30 days without a dismissal comment triggers a maintainer ping — re-triage it to decide between fix / accept / escalate. + +--- + +## Running CodeQL locally (optional, for rapid iteration) + +Install the [CodeQL CLI](https://docs.github.com/en/code-security/codeql-cli/getting-started-with-the-codeql-cli/setting-up-the-codeql-cli) and reproduce the exact CI queries: + +```bash +# 1. Create a database from the compiled TS sources +codeql database create codeql-db \ + --language=javascript-typescript \ + --source-root=. \ + --command="npm run build" + +# 2. Run the same query suite CI uses +codeql database analyze codeql-db \ + codeql/javascript-typescript:codeql/javascript-queries \ + codeql/javascript-typescript:codeql/javascript-experimental \ + --format=sarif-latest \ + --output=codeql-local.sarif \ + --threads=0 + +# 3. Upload / inspect results +codeql github upload-results \ + --sarif=codeql-local.sarif \ + --repository=StableRoute-Org/Stableroute-backend \ + --ref=refs/heads/$(git branch --show-current) \ + --commit=$(git rev-parse HEAD) +``` + +--- + +## Common JavaScript/TypeScript rules seen in this repo + +| Rule id | CWE | Typical trigger | +| --------------------------------------- | ------- | ---------------------------------------------------------- | +| `js/sql-injection` | CWE-89 | Untrusted input interpolated into SQL / CQL strings. | +| `js/path-injection` | CWE-22 | `fs.*` call with user-controlled `path.join()` segment. | +| `js/command-injection` | CWE-78 | `exec` / `spawn` with untrusted arguments. | +| `js/nosql-injection` | CWE-943 | Untrusted keys passed directly to store query objects. | +| `js/hardcoded-credentials` | CWE-798 | Literal secrets or tokens in source (not `.env`). | +| `js/xss` | CWE-79 | Raw string interpolation into HTML/JSONP responses. | +| `js/prototype-pollution` | CWE-1321| Unsafe recursive merge / spread of user-controlled keys. | +| `js/unsafe-deserialization` | CWE-502 | Calling `JSON.parse` on data from a non-authoritative source. | +| `js/clear-text-logging-of-sensitive-data`| CWE-532 | Logging full request body or authorization header values. | +| `js/missing-rate-limiting` | CWE-770 | New public Express route without `rateLimit` middleware. | + +When a new rule fires for the first time, add a row to this table with the remediation pattern that the codebase adopts for it. diff --git a/package-lock.json b/package-lock.json index 063ff3b..67a85f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,12 +19,14 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.9.0", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^8.61.1", "@typescript-eslint/parser": "^8.61.1", "eslint": "^9.14.0", "jest": "^29.7.0", + "js-yaml": "^5.2.2", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", @@ -661,6 +663,29 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -1463,6 +1488,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4690,16 +4722,26 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsesc": { diff --git a/package.json b/package.json index 321bc15..47bd1a9 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,14 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.9.0", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^8.61.1", "@typescript-eslint/parser": "^8.61.1", "eslint": "^9.14.0", "jest": "^29.7.0", + "js-yaml": "^5.2.2", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", diff --git a/src/__tests__/ciWorkflow.test.ts b/src/__tests__/ciWorkflow.test.ts new file mode 100644 index 0000000..c907ae8 --- /dev/null +++ b/src/__tests__/ciWorkflow.test.ts @@ -0,0 +1,335 @@ +import * as fs from "fs"; +import * as path from "path"; +import yaml from "js-yaml"; + +const ROOT = path.resolve(__dirname, "..", ".."); +const CODEQL_WORKFLOW_PATH = path.join( + ROOT, + ".github", + "workflows", + "codeql.yml", +); +const CODEQL_CONFIG_PATH = path.join( + ROOT, + ".github", + "codeql", + "codeql-config.yml", +); +const CI_WORKFLOW_PATH = path.join(ROOT, ".github", "workflows", "ci.yml"); + +function loadYaml(filePath: string): Record { + const raw = fs.readFileSync(filePath, "utf-8"); + return yaml.load(raw) as Record; +} + +describe("CodeQL workflow (.github/workflows/codeql.yml)", () => { + let codeqlWorkflow: Record; + + beforeAll(() => { + codeqlWorkflow = loadYaml(CODEQL_WORKFLOW_PATH); + }); + + it("file exists and is parseable YAML", () => { + expect(fs.existsSync(CODEQL_WORKFLOW_PATH)).toBe(true); + expect(codeqlWorkflow).toBeDefined(); + expect(typeof codeqlWorkflow).toBe("object"); + expect(codeqlWorkflow).not.toBeNull(); + }); + + it("has the expected name", () => { + expect(codeqlWorkflow.name).toMatch(/codeql/i); + }); + + describe("on: triggers", () => { + let on: Record; + + beforeAll(() => { + on = codeqlWorkflow.on as Record; + }); + + it("defines push trigger", () => { + expect(on).toHaveProperty("push"); + const push = on.push as Record; + expect(push).toHaveProperty("branches"); + expect(push.branches).toContain("main"); + }); + + it("defines pull_request trigger", () => { + expect(on).toHaveProperty("pull_request"); + const pr = on.pull_request as Record; + expect(pr).toHaveProperty("branches"); + expect(pr.branches).toContain("main"); + }); + + it("defines weekly schedule trigger", () => { + expect(on).toHaveProperty("schedule"); + const schedule = on.schedule as Array>; + expect(Array.isArray(schedule)).toBe(true); + expect(schedule.length).toBeGreaterThanOrEqual(1); + expect(schedule[0]).toHaveProperty("cron"); + expect(typeof schedule[0].cron).toBe("string"); + const cronParts = (schedule[0].cron as string).trim().split(/\s+/); + expect(cronParts.length).toBe(5); + }); + + it("push has path filters for src/ and workflow files", () => { + const push = on.push as Record; + expect(push).toHaveProperty("paths"); + const paths = push.paths as string[]; + expect(Array.isArray(paths)).toBe(true); + expect(paths.some((p) => p.includes("src/**"))).toBe(true); + expect(paths.some((p) => p.includes("codeql.yml"))).toBe(true); + }); + + it("pull_request has path filters for src/ and workflow files", () => { + const pr = on.pull_request as Record; + expect(pr).toHaveProperty("paths"); + const paths = pr.paths as string[]; + expect(Array.isArray(paths)).toBe(true); + expect(paths.some((p) => p.includes("src/**"))).toBe(true); + expect(paths.some((p) => p.includes("codeql.yml"))).toBe(true); + }); + }); + + describe("jobs.analyze", () => { + let jobs: Record; + let analyze: Record; + + beforeAll(() => { + jobs = codeqlWorkflow.jobs as Record; + analyze = jobs.analyze as Record; + }); + + it("defines an analyze job", () => { + expect(jobs).toHaveProperty("analyze"); + }); + + it("runs on ubuntu-latest", () => { + expect(analyze["runs-on"]).toBe("ubuntu-latest"); + }); + + it("has strategy matrix with javascript-typescript language", () => { + const strategy = analyze.strategy as Record; + expect(strategy).toBeDefined(); + expect(strategy["fail-fast"]).toBe(false); + const matrix = strategy.matrix as Record; + expect(matrix).toHaveProperty("language"); + const languages = matrix.language as string[]; + expect(Array.isArray(languages)).toBe(true); + expect(languages).toContain("javascript-typescript"); + }); + + it("grants security-events write permission for SARIF upload", () => { + const permissions = analyze.permissions as Record; + expect(permissions).toBeDefined(); + expect(permissions["security-events"]).toBe("write"); + }); + + describe("steps", () => { + let steps: Array>; + + beforeAll(() => { + steps = analyze.steps as Array>; + expect(Array.isArray(steps)).toBe(true); + expect(steps.length).toBeGreaterThan(4); + }); + + function findStep( + predicate: (s: Record) => boolean, + ): Record | undefined { + return steps.find(predicate); + } + + it("includes checkout step (actions/checkout@v4)", () => { + const checkout = findStep((s) => + String(s.uses || "").startsWith("actions/checkout"), + ); + expect(checkout).toBeDefined(); + expect(checkout!.uses).toBe("actions/checkout@v4"); + }); + + it("includes Node.js setup step with version 24 and npm cache", () => { + const setupNode = findStep((s) => + String(s.uses || "").startsWith("actions/setup-node"), + ); + expect(setupNode).toBeDefined(); + expect(setupNode!.uses).toBe("actions/setup-node@v4"); + const withObj = setupNode!.with as Record; + expect(withObj["node-version"]).toBe("24"); + expect(withObj.cache).toBe("npm"); + }); + + it("includes npm ci install step", () => { + const install = findStep( + (s) => + typeof s.name === "string" && + /install/i.test(s.name) && + s.run === "npm ci", + ); + expect(install).toBeDefined(); + }); + + it("includes CodeQL init step with config-file and security-and-quality queries", () => { + const init = findStep((s) => + String(s.uses || "").startsWith("github/codeql-action/init"), + ); + expect(init).toBeDefined(); + const withObj = init!.with as Record; + expect(withObj.languages).toBe("${{ matrix.language }}"); + expect(withObj["config-file"]).toBe("./.github/codeql/codeql-config.yml"); + expect(withObj.queries).toBe("security-and-quality"); + }); + + it("includes CodeQL autobuild step", () => { + const autobuild = findStep((s) => + String(s.uses || "").startsWith("github/codeql-action/autobuild"), + ); + expect(autobuild).toBeDefined(); + }); + + it("includes CodeQL analyze step with upload enabled", () => { + const analyze = findStep((s) => + String(s.uses || "").startsWith("github/codeql-action/analyze"), + ); + expect(analyze).toBeDefined(); + const withObj = analyze!.with as Record; + expect(withObj.upload).toBe(true); + expect(withObj.category).toContain("language"); + }); + + it("includes SARIF artifact upload step with 30-day retention", () => { + const upload = findStep((s) => + String(s.uses || "").startsWith("actions/upload-artifact"), + ); + expect(upload).toBeDefined(); + const withObj = upload!.with as Record; + expect(withObj["retention-days"]).toBe(30); + }); + }); + }); +}); + +describe("CodeQL config (.github/codeql/codeql-config.yml)", () => { + let codeqlConfig: Record; + + beforeAll(() => { + codeqlConfig = loadYaml(CODEQL_CONFIG_PATH); + }); + + it("file exists and is parseable YAML", () => { + expect(fs.existsSync(CODEQL_CONFIG_PATH)).toBe(true); + expect(codeqlConfig).toBeDefined(); + expect(typeof codeqlConfig).toBe("object"); + expect(codeqlConfig).not.toBeNull(); + }); + + it("scopes paths to src only", () => { + const paths = codeqlConfig.paths as string[]; + expect(Array.isArray(paths)).toBe(true); + expect(paths.length).toBeGreaterThanOrEqual(1); + expect(paths.some((p) => p === "src" || p.startsWith("src/"))).toBe(true); + }); + + it("excludes node_modules, dist build output, coverage, and __tests__", () => { + const ignore = codeqlConfig["paths-ignore"] as string[]; + expect(Array.isArray(ignore)).toBe(true); + const joined = ignore.join("\n"); + expect(ignore.some((p) => p.includes("node_modules"))).toBe(true); + expect(ignore.some((p) => p.includes("dist"))).toBe(true); + expect(ignore.some((p) => p.includes("coverage"))).toBe(true); + expect(joined).toMatch(/__tests__/); + }); + + it("includes the javascript-typescript CodeQL pack", () => { + const packs = codeqlConfig.packs; + expect(packs).toBeDefined(); + let found = false; + if (Array.isArray(packs)) { + for (const entry of packs) { + if (typeof entry === "string" && entry.includes("javascript-typescript")) { + found = true; + break; + } + if (entry !== null && typeof entry === "object") { + const keys = Object.keys(entry as Record); + if (keys.some((k) => k.includes("javascript-typescript"))) { + found = true; + break; + } + } + } + } else if (packs !== null && typeof packs === "object") { + const keys = Object.keys(packs as Record); + found = keys.some((k) => k.includes("javascript-typescript")); + } + expect(found).toBe(true); + }); +}); + +describe("CI workflow non-regression (ci.yml still intact)", () => { + let ciWorkflow: Record; + + beforeAll(() => { + ciWorkflow = loadYaml(CI_WORKFLOW_PATH); + }); + + it("ci.yml still has build-test job with lint, build, test, coverage steps", () => { + const jobs = ciWorkflow.jobs as Record; + expect(jobs).toHaveProperty("validate-openapi"); + expect(jobs).toHaveProperty("build-test"); + const buildTest = jobs["build-test"] as Record; + const steps = buildTest.steps as Array>; + const stepRuns = steps + .filter((s) => typeof s.run === "string") + .map((s) => s.run as string) + .join("\n"); + expect(stepRuns).toContain("npm run lint"); + expect(stepRuns).toContain("npm run build"); + expect(stepRuns).toContain("npm test"); + expect(stepRuns).toContain("npm run test:coverage"); + }); +}); + +describe("Edge cases and invariants", () => { + it("codeql schedule cron format has exactly 5 whitespace-separated fields", () => { + const wf = loadYaml(CODEQL_WORKFLOW_PATH); + const on = wf.on as Record; + const schedule = on.schedule as Array>; + for (const item of schedule) { + const fields = (item.cron as string).trim().split(/\s+/); + expect(fields).toHaveLength(5); + expect(fields[4]).toBe("1"); + } + }); + + it("no CodeQL step reference includes node_modules or dist in paths inputs", () => { + const wf = loadYaml(CODEQL_WORKFLOW_PATH); + const jobs = wf.jobs as Record; + const analyze = jobs.analyze as Record; + const steps = analyze.steps as Array>; + const dump = JSON.stringify(steps); + expect(dump).not.toContain("node_modules"); + expect(dump).not.toContain("dist/"); + }); + + it("workflow files have LF-compatible line endings (no bare CR)", () => { + for (const p of [CODEQL_WORKFLOW_PATH, CODEQL_CONFIG_PATH]) { + const content = fs.readFileSync(p, "utf-8"); + expect(content).not.toMatch(/\r(?!\n)/); + } + }); + + it("src directory exists to be analyzed", () => { + const srcDir = path.join(ROOT, "src"); + expect(fs.existsSync(srcDir)).toBe(true); + const stat = fs.statSync(srcDir); + expect(stat.isDirectory()).toBe(true); + }); + + it("index.ts is present under src/ (the large target file CodeQL must cover)", () => { + const indexTs = path.join(ROOT, "src", "index.ts"); + expect(fs.existsSync(indexTs)).toBe(true); + const sizeBytes = fs.statSync(indexTs).size; + expect(sizeBytes).toBeGreaterThan(10_000); + }); +});