Skip to content

Commit 0e02fb8

Browse files
Remove src/, preserving the legacy tree as a tag
The legacy flat-PHP application is gone from the working tree. Tagged `legacy-flat-php` (5e34313) before deletion, so it stays findable rather than only reachable by digging through history. I had argued against this because .github/workflows/sast.yml scans src/ alongside laravel/app/ to produce the legacy-vs-Laravel signal comparison -- same 28 vulnerabilities, far fewer findings, because Semgrep recognises Eloquent and Blade as safe. That is the DevSecOps lesson the migration plan predicted in §9. Rather than lose it, the workflow now materialises the legacy tree from the tag with `git archive` into a scratch directory. Same comparison, no legacy code in the working tree. The checkout step gained fetch-depth: 0 so tags are available, and the step degrades to a GitHub warning rather than a hard failure if the tag ever disappears. The SARIF upload now covers laravel/app/ only. Publishing findings against code that is not in the tree would produce Security tab entries pointing at paths nobody can open. Docs updated where they claimed src/ exists: README's version table now points at the tag, legacy-mapping.md §8 records the removal and the retrieval command, architecture.md drops it from the layout tree. All three say the tag must not be deleted, because it is now the only named reference to the pre-port application. Also adds docs/architecture.md -- Mermaid diagrams covering the system overview, where the vulnerabilities concentrate, the two-server MCP contrast, deployment topology, and CI. 82 tests pass.
1 parent 396a347 commit 0e02fb8

36 files changed

Lines changed: 285 additions & 1268 deletions

‎.github/workflows/sast.yml‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ jobs:
2727

2828
steps:
2929
- uses: actions/checkout@v4
30+
with:
31+
# Full history and tags: the legacy tree is materialised from the
32+
# `legacy-flat-php` tag below, not from the working tree.
33+
fetch-depth: 0
3034

3135
- uses: actions/setup-python@v5
3236
with:
@@ -35,12 +39,33 @@ jobs:
3539
- name: Install Semgrep
3640
run: pip install --quiet semgrep
3741

38-
# The two trees are scanned SEPARATELY and on purpose. src/ is the
39-
# original flat PHP; laravel/app is the same behaviour behind a
42+
# src/ was removed from the working tree in July 2026. The comparison it
43+
# feeds is worth keeping, so the legacy code is checked out from the
44+
# `legacy-flat-php` tag into a scratch directory instead.
45+
#
46+
# If this step fails, the tag has been deleted. It is the only remaining
47+
# reference to the pre-port application outside raw git history.
48+
- name: Materialise the legacy tree from the tag
49+
id: legacy
50+
continue-on-error: true
51+
run: |
52+
mkdir -p legacy-tree
53+
if git rev-parse -q --verify refs/tags/legacy-flat-php >/dev/null; then
54+
git archive legacy-flat-php src | tar -x -C legacy-tree
55+
echo "available=true" >> "$GITHUB_OUTPUT"
56+
echo "Legacy tree: $(find legacy-tree -name '*.php' | wc -l) PHP files"
57+
else
58+
echo "available=false" >> "$GITHUB_OUTPUT"
59+
echo "::warning::tag legacy-flat-php not found — skipping the legacy half of the comparison"
60+
fi
61+
62+
# The two trees are scanned SEPARATELY and on purpose. The legacy tree is
63+
# the original flat PHP; laravel/app is the same behaviour behind a
4064
# framework. Same lessons, same flaws, very different scanner output.
41-
- name: Scan legacy src/
65+
- name: Scan legacy tree
66+
if: steps.legacy.outputs.available == 'true'
4267
continue-on-error: true
43-
run: semgrep --config=p/php --config=p/security-audit --json --output=legacy.json --quiet src/ || true
68+
run: semgrep --config=p/php --config=p/security-audit --json --output=legacy.json --quiet legacy-tree/ || true
4469

4570
- name: Scan ported laravel/app
4671
continue-on-error: true
@@ -69,7 +94,7 @@ jobs:
6994
return out
7095
7196
print("## SAST: what framework adoption does to the signal\n")
72-
print(f"- **Legacy `src/` (flat PHP):** {len(legacy)} findings")
97+
print(f"- **Legacy flat PHP** (from tag `legacy-flat-php`): {len(legacy)} findings")
7398
print(f"- **Ported `laravel/app/`:** {len(ported)} findings")
7499
print("")
75100
print("Both trees implement the **same 28 documented vulnerabilities**.")
@@ -97,7 +122,7 @@ jobs:
97122
98123
- name: SARIF for the Security tab
99124
continue-on-error: true
100-
run: semgrep --config=p/php --config=p/security-audit --sarif --output=semgrep.sarif --quiet laravel/app/ src/ || true
125+
run: semgrep --config=p/php --config=p/security-audit --sarif --output=semgrep.sarif --quiet laravel/app/ || true
101126

102127
- name: Upload SARIF
103128
continue-on-error: true

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ dump-based workflow.
3838

3939
| | |
4040
|---|---|
41-
| `src/` | The **original** flat-PHP application. Do not delete: the `SAST` workflow scans it and `laravel/app/` separately on every push, and reports the difference. Same 28 vulnerabilities, far fewer findings — because Semgrep recognises Eloquent and Blade as safe. That contrast is the point. |
41+
| tag `legacy-flat-php` | The **original** flat-PHP application. Removed from the working tree; retrievable with `git checkout legacy-flat-php`. The `SAST` workflow materialises it from this tag to compare scanner output against the port — same 28 vulnerabilities, far fewer findings, because Semgrep recognises Eloquent and Blade as safe. **Do not delete the tag**; it is the only named reference to the pre-port code. |
4242
| `laravel/` | The **current** application: a Laravel 13 port with the same vulnerabilities, an API-first design, and a deliberately vulnerable MCP layer. |
4343

4444
Full catalogue of what is intentional: [`docs/vulnerabilities.md`](docs/vulnerabilities.md).

‎docs/architecture.md‎

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
# Architecture
2+
3+
How PHPVulnBank is put together, and where the deliberate flaws live.
4+
5+
Diagrams are Mermaid and render on GitHub. Companion documents:
6+
[`vulnerabilities.md`](vulnerabilities.md) (the lesson catalogue),
7+
[`api-refactor.md`](api-refactor.md) (why it is API-first),
8+
[`mcp-design.md`](mcp-design.md) (the MCP layer),
9+
[`../SECURITY.md`](../SECURITY.md) (how to run it safely).
10+
11+
---
12+
13+
## 1. System overview
14+
15+
The API is the **system of record**. All business logic — and therefore every
16+
server-side vulnerability — sits behind `/api/v2/*`. The browser client holds
17+
none of it.
18+
19+
```mermaid
20+
flowchart TB
21+
subgraph CLIENTS["Clients"]
22+
BROWSER["Browser<br/><i>thin Blade shells</i>"]
23+
TOOLS["curl · Postman · Burp<br/><i>OpenAPI spec available</i>"]
24+
MCPC["MCP client<br/><i>stdio or HTTP</i>"]
25+
end
26+
27+
subgraph APP["Laravel 13 application"]
28+
direction TB
29+
30+
subgraph ROUTES["Routing"]
31+
WEB["web.php<br/>views only, no logic"]
32+
API["api.php<br/><b>/api/v2/* — system of record</b>"]
33+
AI["ai.php<br/>MCP servers"]
34+
end
35+
36+
MW["Middleware<br/>VulnModeBanner · cookies · session<br/><b>no VerifyCsrfToken on api</b>"]
37+
38+
CTRL["Controllers<br/>Auth · Account · Transfer · Feedback<br/>Admin · Kyc · Register · Utility · OpenApi"]
39+
40+
LQ["<b>LegacyQuery</b><br/><i>single SQLi chokepoint</i>"]
41+
MODELS["Models<br/>User → banktable · Transaction · AuditLog"]
42+
end
43+
44+
DB[("MySQL<br/>bankdb")]
45+
46+
BROWSER --> WEB
47+
WEB -. "fetch()" .-> API
48+
TOOLS --> API
49+
MCPC --> AI
50+
51+
API --> MW --> CTRL
52+
AI --> CTRL
53+
54+
CTRL --> LQ
55+
CTRL --> MODELS
56+
LQ --> DB
57+
MODELS --> DB
58+
59+
classDef vuln fill:#b30000,stroke:#600,color:#fff
60+
class LQ,MW vuln
61+
```
62+
63+
**Why the client is deliberately thin and framework-free.** Moving behind a JSON
64+
API removed the server-side XSS sink — `application/json` does not execute — so
65+
the vulnerability class *moved* to DOM-based rather than disappearing. It now
66+
lives in one `render()` helper in `layouts/app.blade.php` that writes API data
67+
with `innerHTML`. A React or Vue client would auto-escape by default, meaning the
68+
XSS lessons would have to be fought back in, and a bundler would hide the sink.
69+
70+
---
71+
72+
## 2. Where the vulnerabilities concentrate
73+
74+
Not evenly spread. Four chokepoints carry most of them, which is what makes the
75+
repository auditable.
76+
77+
```mermaid
78+
flowchart LR
79+
subgraph SERVER["Server side — survives any client"]
80+
LQ["LegacyQuery<br/>VULN-01 05 06"]
81+
SHELL["UtilityController<br/>VULN-03 08"]
82+
UP["KycController<br/>VULN-04"]
83+
AUTHZ["missing checks<br/>VULN-11 12"]
84+
end
85+
86+
subgraph CLIENT["Browser-dependent — needs the Blade client"]
87+
DOM["render() innerHTML<br/>VULN-13 14"]
88+
CSRF["form-encoded + cookie<br/>VULN-10"]
89+
HDRS["no CSP / X-Frame-Options<br/>VULN-40"]
90+
end
91+
92+
subgraph MCPL["MCP-only — no web analogue"]
93+
POISON["tool #Description<br/>VULN-75"]
94+
T2SQL["run_query<br/>VULN-90 91"]
95+
DEPUTY["shared credential<br/>VULN-81"]
96+
end
97+
98+
classDef vuln fill:#b30000,stroke:#600,color:#fff
99+
class LQ,SHELL,UP,AUTHZ,DOM,CSRF,HDRS,POISON,T2SQL,DEPUTY vuln
100+
```
101+
102+
Two framework defaults had to be **opted out of** deliberately, both documented
103+
in `bootstrap/app.php`:
104+
105+
| Default | Why it was removed |
106+
|---|---|
107+
| `VerifyCsrfToken` on `api` | Needed for `VULN-10`. Also requires form-encoded acceptance — a JSON-only API is not CSRF-able at all |
108+
| `TrimStrings` | Not a security control, but it strips the trailing space from `' or '1'='1' -- `, and MySQL only treats `--` as a comment when followed by whitespace |
109+
110+
---
111+
112+
## 3. The MCP layer — the A/B contrast
113+
114+
Two servers, deliberately. The point is not either one alone, it is the
115+
difference between them.
116+
117+
```mermaid
118+
flowchart LR
119+
CLIENT["MCP client"]
120+
121+
subgraph SRV["MCP servers"]
122+
APISRV["<b>phpvulnbank-api</b><br/>8 tools<br/><i>via application layer</i>"]
123+
DBSRV["<b>phpvulnbank-db</b><br/>4 tools<br/><i>direct connection</i>"]
124+
end
125+
126+
GUARD["McpGuard<br/><i>fails closed without<br/>PHPVULNBANK_LAB=1</i>"]
127+
128+
CONTROLS["Application layer<br/>authorisation · validation<br/>masking · <b>audit_logs</b>"]
129+
130+
DB[("MySQL<br/><i>groot: ALL PRIVILEGES</i>")]
131+
132+
CLIENT --> GUARD --> APISRV
133+
GUARD --> DBSRV
134+
APISRV --> CONTROLS --> DB
135+
DBSRV == "bypasses everything" ==> DB
136+
137+
classDef vuln fill:#b30000,stroke:#600,color:#fff
138+
class DBSRV vuln
139+
```
140+
141+
Run the same action through each, then diff `audit_logs`: one leaves a trail,
142+
the other leaves nothing (`VULN-92`). Every control this application implements
143+
lives in the application layer, and a tool that opens its own connection
144+
discards all of it at once while looking like a sensible latency decision.
145+
146+
Both transports are registered. `stdio` for a student running the lab locally;
147+
HTTP (`POST /mcp/api`, `POST /mcp/db`) so a shared classroom instance is usable
148+
at all, since a client cannot launch a subprocess on someone else's machine.
149+
The HTTP endpoints are unauthenticated — `VULN-80`.
150+
151+
---
152+
153+
## 4. Deployment
154+
155+
```mermaid
156+
flowchart TB
157+
subgraph HUB["Docker Hub"]
158+
IMG["krishnapadala55/phpvulnbank<br/>laravel-bundled-vulnerable-1.0"]
159+
end
160+
161+
subgraph COMPOSE["Compose — primary path"]
162+
APPC["app<br/>php:8.3-apache"]
163+
MYC[("mysql:8.4<br/><i>127.0.0.1 only</i>")]
164+
APPC --- MYC
165+
end
166+
167+
subgraph BUNDLED["Bundled — one command"]
168+
ONE["Apache + PHP + MariaDB<br/>in one container"]
169+
end
170+
171+
LAN["LAN / WireGuard<br/>port 8090, all interfaces"]
172+
173+
IMG -.->|docker run| BUNDLED
174+
COMPOSE --> LAN
175+
BUNDLED --> LAN
176+
177+
classDef warn fill:#b30000,stroke:#600,color:#fff
178+
class LAN warn
179+
```
180+
181+
`migrate:fresh --seed` rebuilds the whole lab from empty on every start, so a
182+
container is disposable and a student who drops the database costs one command.
183+
184+
**Reaching port 8090 is equivalent to shell access on the container** — two
185+
unauthenticated RCE paths (`VULN-02`, `VULN-03`) plus unauthenticated SQL over
186+
MCP. Isolated lab network only. MySQL stays bound to loopback and is not
187+
widened alongside the app.
188+
189+
---
190+
191+
## 5. CI
192+
193+
```mermaid
194+
flowchart LR
195+
PUSH["push / PR"]
196+
197+
TESTS["<b>tests.yml</b><br/>82 tests<br/><i>THE GATE</i>"]
198+
SAST["sast.yml<br/>Semgrep, legacy tag vs laravel/app/"]
199+
DAST["dast.yml<br/>ZAP, manual + weekly"]
200+
201+
PUSH --> TESTS
202+
PUSH --> SAST
203+
DAST
204+
205+
classDef gate fill:#0a6,stroke:#064,color:#fff
206+
class TESTS gate
207+
```
208+
209+
**`tests.yml` is the only gate**, and it is inverted from the usual direction:
210+
most of the suite asserts that vulnerabilities **still work**. A failure means a
211+
lesson has been silently repaired — by a framework upgrade, a linter, or a
212+
well-meaning contributor — which is this project's primary risk.
213+
214+
The scanners are teaching material, not gates. `sast.yml` scans the legacy tree
215+
and `laravel/app/` **separately** and reports the delta: same 28 vulnerabilities,
216+
far fewer findings, because Semgrep recognises Eloquent and Blade as safe. The
217+
legacy tree is no longer in the working directory — the workflow materialises it
218+
from the `legacy-flat-php` tag, so that tag must not be deleted. `dast.yml` frames its output as a coverage gap — a
219+
scanner finds reflected XSS and missing headers, not the IDOR, the
220+
negative-amount transfer, the race condition, or the `troy` backdoor.
221+
222+
CodeQL is not used: **it does not support PHP.**
223+
224+
---
225+
226+
## 6. Repository layout
227+
228+
```
229+
├── laravel/ the application
230+
│ ├── app/
231+
│ │ ├── Http/Controllers/Api/V2/ all business logic
232+
│ │ ├── Mcp/{Servers,Tools}/ MCP layer
233+
│ │ ├── Models/ User → banktable, Transaction, AuditLog
234+
│ │ └── Support/ LegacyQuery, McpGuard, OpenApiSpec
235+
│ ├── resources/views/ thin Blade shells + the innerHTML render helper
236+
│ ├── routes/ web.php · api.php · ai.php
237+
│ ├── tests/Feature/Exploits/ asserts vulnerabilities still work
238+
│ ├── Dockerfile compose variant
239+
│ └── Dockerfile.bundled single-container variant
240+
├── payload/csrf/ CSRF proof of concept
241+
├── docs/ this file and the design documents
242+
└── SECURITY.md read before running
243+
```
244+
245+
Removed in July 2026: the legacy application (`src/`), its build scaffolding
246+
(root `Dockerfile`, `dock/`, `dbscript/`), the Jenkins and Azure pipelines, the
247+
`DevSecOpS/` scan scripts, and the unused Vite/npm chain.
248+
249+
The legacy application is tagged **`legacy-flat-php`** — `git checkout
250+
legacy-flat-php` retrieves it, and `sast.yml` materialises it from there for the
251+
comparison above. The published legacy Docker images are self-contained and
252+
still run.

‎docs/legacy-mapping.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ Flagging explicitly for the guardrails discussion, not as a question about wheth
322322
- The old `Dockerfile` was based on `ubuntu:24.04` and installed Apache + PHP + MySQL + **openssh-server** in one image, exposing port 22. SSH read as convenience rather than a lesson and did not carry over.
323323
- `dock/dock.sh` **prompted interactively** (`read ch`) for SSH account creation, so the container only started under `-it` and would hang in any non-interactive CI. It is also the reason `.gitattributes` now pins `*.sh text eol=lf` — checked out with CRLF it failed with "bad interpreter".
324324
- MySQL was bootstrapped with `mysql -u root` and no password, and the SQL script granted `groot` `ALL PRIVILEGES ON *.*` (VULN-22). The Laravel bundled container reproduces that grant deliberately — `VULN-91` depends on it.
325-
- **`src/` is deliberately retained.** It has a live consumer: `.github/workflows/sast.yml` scans it alongside `laravel/app/` to produce the signal-comparison described at the end of this section. Deleting it would silence that lesson.
325+
- **`src/` was removed from the working tree** in July 2026 and tagged **`legacy-flat-php`** first. `.github/workflows/sast.yml` materialises it from that tag with `git archive`, so the legacy-vs-Laravel signal comparison still runs. Retrieve the code with `git checkout legacy-flat-php`. **Do not delete that tag** — it is the only named reference to the pre-port application, and the workflow degrades to a warning without it.
326326
- **All legacy CI has been removed** (July 2026). For the record, since the analysis informed the port and the archived DAST report still refers to it:
327327
- `DevSecOpS/DAST_Scan_Zap.ps1` targeted `http://127.0.0.1:8090/phpvulnbank/`, but `dock.sh` printed `http://localhost:8090/login.php` — the scan path looked wrong for the container layout and would have found nothing at that URL. Treat the archived DAST results with that in mind.
328328
- `DevSecOpS/fortifyscan.ps` was a duplicate of `fortifyscan.ps1` left behind by a rename (commit `b523854`).

‎src/activate.php‎

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)