Skip to content

Commit c14f30b

Browse files
Merge branch 'main' into pr-352-fix-i18n
2 parents ffdef3d + 00d752e commit c14f30b

40 files changed

Lines changed: 3262 additions & 148 deletions

.github/security/gitleaks.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# gitleaks configuration for wata-board.
2+
# Extends the default ruleset. Allowed/false-positive secrets are recorded in
3+
# .github/security/gitleaks-allowlist.txt as commit SHAs (one per line) and are
4+
# reviewed with an expiry. See docs/SECURITY_PIPELINE.md.
5+
6+
[extend]
7+
useDefault = true
8+
9+
# Allowlist of paths known to contain example/test secrets (not real secrets).
10+
[[allowlist]]
11+
description = "Ignore example/test fixtures"
12+
paths = [
13+
'''security-tests/tests/.*''',
14+
'''backend/src/test/.*''',
15+
'''frontend/src/test/.*''',
16+
'''.env.example''',
17+
]
18+
19+
# Regex-based allowlist for clearly-fake placeholder values.
20+
[[allowlist]]
21+
description = "Ignore obvious placeholders"
22+
regexes = [
23+
'''example-password''',
24+
'''change-me''',
25+
'''YOUR_.*_HERE''',
26+
'''gho_\*+''',
27+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Semgrep Baseline Policy
2+
3+
New HIGH or CRITICAL findings from Semgrep fail the security pipeline.
4+
Accepted/false-positive findings should be suppressed inline in code with
5+
`# nosemgrep: <rule-id>` and a short rationale comment, rather than added to
6+
a global allowlist, so the suppression is reviewed at the code site.
7+
8+
If a global suppression is unavoidable, add the rule id below with an owner
9+
and an expiry date. The security team reviews this file quarterly.
10+
11+
| Rule ID | Reason | Owner | Expiry |
12+
|--------|--------|-------|--------|
13+
| _(none)_ | | | |
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
name: Security Pipeline
2+
3+
# Unified security gate: SAST, SCA, secret scanning, IaC scanning, and SBOM.
4+
# Runs on every PR and nightly on main. New high/critical findings, new secrets,
5+
# or high-severity IaC misconfigs block the merge gate.
6+
# See docs/SECURITY_PIPELINE.md for policy, baselines, and runbooks.
7+
8+
on:
9+
push:
10+
branches: [main]
11+
pull_request:
12+
branches: [main]
13+
schedule:
14+
# nightly at 03:00 UTC
15+
- cron: "0 3 * * *"
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: read
20+
security-events: write
21+
actions: read
22+
23+
concurrency:
24+
group: security-${{ github.ref }}
25+
cancel-in-progress: false
26+
27+
jobs:
28+
# ---------------------------------------------------------------
29+
# Secret scanning — fail on any new leaked secret (gitleaks)
30+
# ---------------------------------------------------------------
31+
secret-scan:
32+
name: Secret Scan (gitleaks)
33+
runs-on: ubuntu-latest
34+
steps:
35+
- uses: actions/checkout@v4
36+
with: { fetch-depth: 0 }
37+
- name: gitleaks
38+
uses: gitleaks/gitleaks-action@v2
39+
env:
40+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41+
GITLEAKS_ENABLE_COMMENTS: "true"
42+
with:
43+
config-path: .github/security/gitleaks.toml
44+
45+
# ---------------------------------------------------------------
46+
# SAST — Semgrep with fail-gate on high/critical findings
47+
# ---------------------------------------------------------------
48+
sast:
49+
name: SAST (semgrep)
50+
runs-on: ubuntu-latest
51+
steps:
52+
- uses: actions/checkout@v4
53+
- name: Semgrep scan
54+
uses: returntocorp/semgrep-action@v1
55+
with:
56+
config: >-
57+
p/typescript
58+
p/javascript
59+
p/rust
60+
p/owasp-top-ten
61+
p/react
62+
p/nodejs
63+
p/docker
64+
p/sql-injection
65+
p/xss
66+
p/security-audit
67+
# Baseline: known/accepted findings live in .github/security/semgrep-baseline.sarif.
68+
# New HIGH/CRITICAL findings fail the job.
69+
- name: Upload Semgrep SARIF
70+
if: always()
71+
uses: github/codeql-action/upload-sarif@v3
72+
with:
73+
sarif_file: semgrep.sarif
74+
75+
# ---------------------------------------------------------------
76+
# SCA + IaC — Trivy (filesystem for vulns, config for IaC misconfigs)
77+
# ---------------------------------------------------------------
78+
sca-and-iac:
79+
name: SCA + IaC (trivy)
80+
runs-on: ubuntu-latest
81+
steps:
82+
- uses: actions/checkout@v4
83+
- name: Trivy FS (dependency vulnerabilities) — fail on HIGH/CRITICAL
84+
uses: aquasecurity/trivy-action@0.24.0
85+
with:
86+
scan-type: fs
87+
scan-ref: .
88+
severity: HIGH,CRITICAL
89+
exit-code: "1"
90+
ignore-unfixed: true
91+
format: sarif
92+
output: trivy-fs.sarif
93+
- name: Trivy config (IaC misconfigs) — fail on HIGH/CRITICAL
94+
uses: aquasecurity/trivy-action@0.24.0
95+
with:
96+
scan-type: config
97+
scan-ref: .
98+
severity: HIGH,CRITICAL
99+
exit-code: "1"
100+
format: sarif
101+
output: trivy-config.sarif
102+
- name: Upload Trivy SARIF
103+
if: always()
104+
uses: github/codeql-action/upload-sarif@v3
105+
with:
106+
sarif_file: trivy-fs.sarif
107+
108+
# ---------------------------------------------------------------
109+
# SBOM — CycloneDX via Syft (signed artifact) per build
110+
# ---------------------------------------------------------------
111+
sbom:
112+
name: SBOM (syft)
113+
runs-on: ubuntu-latest
114+
steps:
115+
- uses: actions/checkout@v4
116+
- name: Generate CycloneDX SBOM
117+
uses: anchore/sbom-action@v0
118+
with:
119+
format: cyclonedx-json
120+
output-file: sbom.cyclonedx.json
121+
upload-artifact: true
122+
upload-artifact-name: sbom.cyclonedx.json
123+
- name: Upload SBOM artifact
124+
uses: actions/upload-artifact@v4
125+
with:
126+
name: sbom-cyclonedx
127+
path: sbom.cyclonedx.json
128+
retention-days: 90
129+
130+
# ---------------------------------------------------------------
131+
# Aggregate security report (digest) — non-blocking, always runs
132+
# ---------------------------------------------------------------
133+
security-report:
134+
name: Security Report Digest
135+
runs-on: ubuntu-latest
136+
needs: [secret-scan, sast, sca-and-iac, sbom]
137+
if: always()
138+
steps:
139+
- uses: actions/checkout@v4
140+
- name: Setup Node
141+
uses: actions/setup-node@v4
142+
with: { node-version: "20" }
143+
- name: Install
144+
run: npm ci
145+
working-directory: ./security-tests
146+
- name: Generate report
147+
run: npm run report:security
148+
working-directory: ./security-tests
149+
if: always()
150+
- name: Upload report
151+
uses: actions/upload-artifact@v4
152+
if: always()
153+
with:
154+
name: security-report-digest
155+
path: security-tests/reports/
156+
retention-days: 30
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { idempotency, MemoryIdempotencyStore } from '../middleware/idempotency';
2+
import { Request, Response } from 'express';
3+
4+
type FakeRes = Response & {
5+
captured: any;
6+
statusCode: number;
7+
jsoned: boolean;
8+
headers: Record<string, string>;
9+
};
10+
11+
function mkRes(): FakeRes {
12+
const r: any = {
13+
statusCode: 200,
14+
jsoned: false,
15+
captured: undefined,
16+
headers: {},
17+
set(k: string, v: string) { this.headers[k.toLowerCase()] = v; return this; },
18+
status(code: number) { this.statusCode = code; return this; },
19+
json(body: any) { this.captured = body; this.jsoned = true; return this; },
20+
};
21+
return r as FakeRes;
22+
}
23+
24+
function mkReq(headers: Record<string, string> = {}, path = '/api/v1/payment', method = 'POST'): Request {
25+
return { headers, path, method, route: { path } } as unknown as Request;
26+
}
27+
28+
/** Run the middleware and resolve once it either calls next() or replies. */
29+
function run(mw: any, req: Request, res: FakeRes): Promise<{ next: boolean }> {
30+
return new Promise<{ next: boolean }>((resolve) => {
31+
let done = false;
32+
const finish = (nextCalled: boolean) => {
33+
if (!done) { done = true; resolve({ next: nextCalled }); }
34+
};
35+
// resolve on next()
36+
const next = () => finish(true);
37+
// resolve when the middleware writes a response
38+
const origJson = res.json.bind(res);
39+
res.json = ((body: any) => {
40+
const out = origJson(body);
41+
finish(false);
42+
return out;
43+
}) as Response['json'];
44+
mw(req, res as unknown as Response, next);
45+
});
46+
}
47+
48+
describe('idempotency middleware', () => {
49+
it('passes through when no Idempotency-Key header is present', async () => {
50+
const store = new MemoryIdempotencyStore();
51+
const mw = idempotency({ store });
52+
const res = mkRes();
53+
const { next } = await run(mw, mkReq({}), res);
54+
expect(next).toBe(true);
55+
expect(res.jsoned).toBe(false);
56+
});
57+
58+
it('first request processes; identical retry replays the cached response', async () => {
59+
const store = new MemoryIdempotencyStore();
60+
const mw = idempotency({ store, ttlSeconds: 60 });
61+
const key = 'abc-123';
62+
63+
const res1 = mkRes();
64+
const r1 = await run(mw, mkReq({ 'idempotency-key': key }), res1);
65+
expect(r1.next).toBe(true);
66+
// simulate the route handler writing the response (captured by our patch)
67+
res1.status(200).json({ success: true, transactionId: 'tx-1' });
68+
69+
const res2 = mkRes();
70+
const r2 = await run(mw, mkReq({ 'idempotency-key': key }), res2);
71+
expect(r2.next).toBe(false); // replayed from cache, handler not invoked
72+
expect(res2.statusCode).toBe(200);
73+
expect(res2.captured).toEqual({ success: true, transactionId: 'tx-1' });
74+
expect(res2.headers['x-idempotent-replay']).toBe('true');
75+
});
76+
77+
it('returns 409 when a request with the same key is already in flight', async () => {
78+
const store = new MemoryIdempotencyStore();
79+
const mw = idempotency({ store, ttlSeconds: 60 });
80+
const key = 'inflight-key';
81+
82+
const res1 = mkRes();
83+
await run(mw, mkReq({ 'idempotency-key': key }), res1); // acquired, "in flight"
84+
85+
const res2 = mkRes();
86+
const r2 = await run(mw, mkReq({ 'idempotency-key': key }), res2);
87+
expect(r2.next).toBe(false);
88+
expect(res2.statusCode).toBe(409);
89+
expect(res2.captured.error).toBe('IDEMPOTENCY_IN_FLIGHT');
90+
});
91+
92+
it('does not cache 5xx so the client can retry', async () => {
93+
const store = new MemoryIdempotencyStore();
94+
const mw = idempotency({ store, ttlSeconds: 60 });
95+
const key = 'err-key';
96+
97+
const res1 = mkRes();
98+
await run(mw, mkReq({ 'idempotency-key': key }), res1);
99+
res1.status(500).json({ error: 'boom' });
100+
101+
const res2 = mkRes();
102+
const r2 = await run(mw, mkReq({ 'idempotency-key': key }), res2);
103+
expect(r2.next).toBe(true); // not replayed — handler runs again
104+
});
105+
106+
it('scopes keys per route (same client key, different route => independent)', async () => {
107+
const store = new MemoryIdempotencyStore();
108+
const mw = idempotency({ store, ttlSeconds: 60 });
109+
const key = 'shared-key';
110+
111+
const resA = mkRes();
112+
await run(mw, mkReq({ 'idempotency-key': key }, '/api/v1/payment'), resA);
113+
resA.status(200).json({ route: 'a' });
114+
115+
const resB = mkRes();
116+
const rB = await run(mw, mkReq({ 'idempotency-key': key }, '/api/v2/payment'), resB);
117+
expect(rB.next).toBe(true); // different route -> not a replay
118+
});
119+
});

0 commit comments

Comments
 (0)