Every program that touches a network has strangers using it, and some of them are trying to make it do something you never meant. Security is the craft of writing code that does only what you meant, even when its input is chosen by someone hostile.
The good news: most real attacks exploit a handful of old, well-understood mistakes. Learn the mistakes and their fixes, and you are ahead of most code on the internet. On the stage is one small app. Each act attacks it twice: once against careless code (red), once against the fix (green).
Every code block runs in your browser. Edit it, break it, press Run.
01 · THREAT MODEL
Think like an attacker: the threat model
You can't defend everything equally, so start by drawing a map. A threat model names what is worth protecting (the assets: the user table, the payment keys), where strangers can reach in (the entry points), and the trust boundaries: the lines where data crosses from someone you don't trust to something you do. On the stage, the violet wall is that line. Everything left of it, including your own users' browsers, is outside your control.
The whole practice fits in four questions: What are we working on? What can go wrong? What are we going to do about it? Did we do a good enough job? For "what can go wrong", Microsoft's STRIDE is a useful checklist: Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege.
threat-model.js
12345678910111213141516171819202122
// A threat model for one feature, written as data.
// STRIDE: six ways things go wrong (Microsoft, 1999).
constfeature="Log in with email and password";
constthreats=[
{kind:"Spoofing",what:"someone logs in as alice by guessing",likely:3,harm:3,fix:"rate limit, MFA"},
{kind:"Tampering",what:"request body changed on the wire",likely:1,harm:3,fix:"HTTPS everywhere"},
{kind:"Repudiation",what:"\"I never logged in from there\"",likely:2,harm:1,fix:"audit log of logins"},
{kind:"Info disclosure",what:"password readable in the database",likely:2,harm:3,fix:"slow salted hash"},
{kind:"Denial of service",what:"flood of slow hash requests",likely:2,harm:2,fix:"rate limit per IP"},
{kind:"Elevation",what:"normal user reaches the admin page",likely:2,harm:3,fix:"check role on the server"},
];
// Rank by risk = likelihood × impact, and fix the top first.
Rank by likelihood × impact, then fix from the top. Change a number and watch the order change. A threat model is a living document: redo it when the feature changes.
One rule follows from the map: never trust anything that crosses the boundary. Form fields, URLs, headers, cookies, uploaded files, even the JavaScript in your own page: the attacker controls their browser completely, so every check that matters must happen again on the server.
02 · OWASP TOP 10
The OWASP Top 10
The OWASP Foundation (the Open Worldwide Application Security Project) publishes a ranked list of the most critical risks to web applications, built from testing data contributed by security teams plus a survey of practitioners. The current edition is 2025; the stage lights its ten categories in turn and points at where each one lives in our app.
owasp-2025.js
12345678910111213141516171819
// The OWASP Top 10, 2025 edition, and where each one was in 2021.
consttop10=[
["A01","Broken Access Control",1],
["A02","Security Misconfiguration",5],
["A03","Software Supply Chain Failures",6],// grew out of "Vulnerable and Outdated Components"
["A04","Cryptographic Failures",2],
["A05","Injection",3],
["A06","Insecure Design",4],
["A07","Authentication Failures",7],
["A08","Software or Data Integrity Failures",8],
["A09","Security Logging and Alerting Failures",9],
["A10","Mishandling of Exceptional Conditions",null],// new in 2025
Broken access control is still number one. Supply-chain failures climbed to third, and A10 is new: what your code does when something fails.
The list is a map of categories, not a checklist to tick. Several fit together: Injection (A05) is mixing data into code; Cryptographic Failures (A04) includes storing passwords badly; Authentication Failures (A07) covers weak logins and sessions. The rest of the chapter takes the most important ones, one per act.
1, 2, 3the ranks of cross-site scripting, SQL injection and cross-site request forgery on MITRE's 2025 list of the most dangerous software weaknesses. Missing authorization is 4th. They are the next four acts. MITRE 2025 CWE Top 25
03 · SQL INJECTION
SQL injection: when data becomes code
A database speaks SQL, a language. If your program builds a SQL sentence by gluing user input into it, the user gets to write part of your program. The quote character ends your string early, and whatever follows is read as SQL.
To show it safely, each block below carries its own tiny SQL engine (a real lexer and parser, about 60 lines, at the bottom). Watch the "words" line: with the attacker's input, OR stops being part of a name and becomes a keyword.
]};// (a toy keeps plain passwords; act 7 shows what real apps store)
}
-- starts a comment in SQL, so the password test is simply cut off.
Here is exactly what the database received, in a console that runs SQL on the course's own little SQL engine (the same one as in Data and databases). Run it, then change the glued-in parts and see what else an attacker could write.
what-the-database-got.sql
12345
-- The search, after gluing in: x' OR '1'='1
SELECTname,emailFROMusersWHEREname='x'OR'1'='1';
-- The login, after gluing in: alice' --
SELECTnameFROMusersWHEREname='alice'--' AND password = 'anything';
Tables: users(id, name, email, password). Each run starts from fresh data.
Both statements are valid SQL. The database can't know which parts the developer meant and which the visitor typed: that is the whole problem.
The fix is not to hunt for dangerous characters. It is to never let input be parsed as SQL at all. A parameterized query (or prepared statement) sends the SQL with ? placeholders, and the values separately; the database parses the SQL first, then fills the holes with values. A value can't end a string that it was never part of.
]};// (a toy keeps plain passwords; act 7 shows what real apps store)
}
Same inputs, nothing dumped, and a name like O'Brien no longer breaks anything. Fixing the security bug fixed a correctness bug too.
Python · sqlite3
# Python, sqlite3: the driver sends the SQL and the values separatelycur.execute("SELECT name, email FROM users WHERE name = ?",(name,))# Never: cur.execute(f"SELECT ... WHERE name = '{name}'")
Node.js · node-postgres
// Node.js, node-postgres: $1, $2 ... are placeholdersconst{rows}=awaitpool.query("SELECT name, email FROM users WHERE name = $1",[name]);
PostgreSQL · what a prepared statement is
-- What the database sees: a statement prepared once...PREPAREfind_user(text)ASSELECTname,emailFROMusersWHEREname=$1;-- ...then run with a value. The value is never parsed as SQL.EXECUTEfind_user('x'' OR ''1''=''1');-- 0 rows
Placeholders can only stand for values, not for table or column names. If a user picks the column to sort by, check it against an allowlist of names you wrote. ORMs and query builders parameterize for you, until you reach for their "raw SQL" escape hatch.
04 · XSS
XSS: the same mistake, in the browser
Cross-site scripting is injection into HTML. If your page puts a user's comment into the page as raw HTML, then a comment containing a <script> tag runs as JavaScript, in every reader's browser, with your site's permissions: it can read the page, act as the user, and send their data away. On the stage, the attacker posts a comment, the server serves it to you, and your session leaks back to evil.example.
The demo below never touches this page. Its "browser" is a toy that only reports what a real one would treat as code.
comments.js
12345678910111213141516171819
// A comment box, and a TOY browser. The toy only reports what a real
// browser would treat as code; nothing here touches a real page.
functionrenderComment(text){
return'<li class="comment">'+text+"</li>";// glued: text becomes HTML
Two ways in: a script tag, and an event-handler attribute like onerror. Blocking the word "script" would miss the second, and dozens more.
The fix is output encoding: when you put text into HTML, turn the characters HTML treats as special into entities, so the browser draws them instead of obeying them. < becomes < and shows as a < on screen.
comments-fixed.js
123456789101112131415161718192021222324
// Output encoding: turn the five special characters into entities, so
// the browser shows them as text instead of reading them as markup.
functionescapeHtml(s){
returnString(s)
.replaceAll("&","&")// first, or it would double-escape the others
console.log("a real one: <a href=\""+escapeHtml(safeUrl("https://example.com/me"))+"\">");
In practice: let your framework do it. React, Vue, Svelte and every modern template engine escape text by default, and the DOM has a safe API. The danger is the escape hatches:
the DOM and React
// In the real DOM, choose the API that treats input as text:el.textContent=comment;// ✓ always textel.innerHTML=comment;// ✗ parses markup: only for HTML you trust// React escapes {comment} for you. This opts out, so treat it like innerHTML:<divdangerouslySetInnerHTML={{__html:comment}}/>
Then add a second layer. A Content Security Policy header tells the browser which scripts may run at all, so an injection that slips through still can't execute inline code. Cookies marked HttpOnly can't be read by JavaScript, so a successful XSS can't simply copy them.
HTTP response header
# A response header that tells the browser which scripts may run at all.# Even if an injection slips through, inline <script> and onerror= are refused.Content-Security-Policy:default-src'self';script-src'self';object-src'none';base-uri'none'
1 millionMySpace profiles infected in about 20 hours in October 2005 by one stored XSS that added its author as a friend and copied itself to every profile that viewed it. Samy worm (2005)
05 · CSRF
CSRF: a request you didn't mean to send
Your browser attaches a site's cookies to every request to that site, automatically, no matter which page started it. So if you are logged in to your bank and visit evil.example, a hidden form there can make your browser send your bank a transfer, with your session cookie on it. The bank sees a perfectly valid, logged-in request. That is cross-site request forgery.
bank.js
1234567891011121314151617181920212223242526
// A toy bank, a toy browser and two websites. Everything is simulated.
console.log(browserSend("evil.example",forged,"None",false));// ✗ the attack works
console.log(browserSend("evil.example",forged,"None",true));// ✓ token stops it
console.log(browserSend("evil.example",forged,"Lax",false));// ✓ cookie never sent
Four requests: the real one, the forged one with no defences, and two defences, each enough on its own here. Use both.
The defences, in layers:
SameSite cookies.SameSite=Lax (or Strict) tells the browser not to send the cookie on requests started by another site. Chrome treats cookies with no SameSite attribute as Lax.
A CSRF token. A random value the server puts in its own forms and checks on every state-changing request. evil.example can make your browser send a request, but it can't read your bank's pages to learn the token.
Check Origin. Browsers say which site started a request; refuse ones from elsewhere.
Never change anything on GET. Lax cookies still ride along on top-level links, so a link must never move money.
a session cookie, set properly
Set-Cookie:session=sess-71f3;Secure;HttpOnly;SameSite=Lax;Path=/# Secure only over HTTPS# HttpOnly JavaScript cannot read it (an XSS can't simply steal it)# SameSite not sent on cross-site POSTs, fetches or iframes
06 · WHO & WHAT
Authentication vs authorization
Two words that sound alike and fail differently. Authentication answers who are you? (a password, a passkey, a code from your phone). Authorization answers are you allowed to do this? On the stage the gate checks who you are; then the server must still check, at the table, whether this row is yours.
invoices.js
123456789101112131415161718192021222324252627
// Authentication: WHO are you? Authorization: what may you DO?
show(()=>getInvoiceBad(alice,2));// ✗ alice reads bob's invoice by changing ?id=2
show(()=>getInvoice(alice,1));// ✓ her own
show(()=>getInvoice(alice,2));// ✓ refused
show(()=>getInvoice({user:"root"},2));
The first call is the bug: alice is properly logged in, and reads bob's invoice just by changing the number in the URL. This is the most common item in OWASP's number-one category.
Rules that prevent most of it:
Check on the server, on every request. Hiding a button in the page is not a check; the attacker sends the request without your page.
Deny by default. Write rules that grant, so that anything nobody thought about is refused.
Put the check in one place (a middleware or a policy function) that every route goes through, so a new endpoint can't forget it.
Fail closed. If the permission lookup errors, the answer is no. Exercise 4 is this bug.
For authentication itself: don't build it from scratch if you can avoid it. Use your framework's, offer multi-factor sign-in or passkeys, rate-limit login attempts, and when you must store passwords, store them the way the next act shows.
07 · PASSWORDS
Storing passwords: salt and slow hashes
Never store a password. Store something that can check one. A hash function turns any input into a fixed-size fingerprint and can't be run backwards, so you keep hash(password) and compare hashes at login. But a plain fast hash like SHA-256 fails in two ways. Run this:
fast-hash.js
123456789101112131415
// Why not just SHA-256 the password? Two reasons. Run it and see both.
console.log("2. this one thread tries "+perSec.toLocaleString("en")+" guesses a second");
console.log(" a GPU rig: tens of billions a second");
Same password, same hash, so one cracked hash cracks every user who chose it. And a fast hash is fast for the attacker too: a stolen table can be guessed against at billions of tries a second on GPUs.
The fix has two parts. A salt: a random value, unique per user, mixed in before hashing and stored next to the hash. And a deliberately slow function with a tunable cost, so each guess costs the attacker real time. On the stage, the password and its salt go through the mill 600,000 times before the result reaches the table.
Each hash takes a noticeable fraction of a second, which nobody notices at login and which makes guessing millions of times slower. The comparison checks every character, so its timing reveals nothing.
The browser only has PBKDF2 built in. On a server, use the OWASP order of preference: Argon2id (memory-hard, so GPUs and custom chips gain little), then scrypt, then bcrypt for older systems, PBKDF2 where FIPS compliance requires it. Use a maintained library; never write your own.
Python · argon2-cffi
# Python: argon2-cffi (Argon2id, the current first choice)fromargon2importPasswordHasherph=PasswordHasher()# Argon2id, salted, sensible defaultsstored=ph.hash("correct horse")# '$argon2id$v=19$m=65536,t=3,p=4$...'ph.verify(stored,"correct horse")# True, or raises VerifyMismatchError
600,000PBKDF2-HMAC-SHA256 iterations OWASP recommends. For Argon2id the minimum is 19 MiB of memory, 2 iterations, 1 degree of parallelism; for bcrypt, a cost of at least 10. OWASP Password Storage Cheat Sheet
To feel how fast guessing is from the other side, try Crack My Password: it shows how long your password would last against a real cracking rig.
08 · SECRETS
Secrets: never in the code
API keys, database passwords and signing keys are secrets: whoever holds one is your app, as far as the other side can tell. Put one in your source code and it is in every copy: every clone, every fork, every laptop, every backup, every CI log, and forever in the git history, even after you delete the line.
Scanners run on every public push, both the good ones (GitHub's push protection) and attackers'. Here is the core of one: known key formats, plus a measure of randomness called entropy for keys with no known format.
Three findings in five files. db.js is the right pattern: the code names the secret, and the value arrives at run time.
read it from the environment
// Read secrets from the environment at run time, and fail loudly if missing.constkey=process.env.PAYMENTS_KEY;if(!key)thrownewError("PAYMENTS_KEY is not set");
.gitignore and .env.example
# .gitignore: the real values never enter git.env*.pem# .env.example IS committed: the names, with no valuesDATABASE_URL=PAYMENTS_KEY=
Better still, keep secrets in a secrets manager (a vault: AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, your platform's encrypted settings) that hands them to the app at run time, logs who read them, and lets you rotate them without a deploy. Install a pre-commit scanner (gitleaks, trufflehog) so a key is caught before it ever leaves your laptop. See how history keeps everything on Git Time Machine.
A typical app is mostly code you didn't write. Every package you install runs with your app's full permissions, and so does every package it installs. The tree on the stage is the one in this program: you chose three packages, and you run thirteen.
deps.js
12345678910111213141516171819202122232425262728
// Your app's dependency graph: what you chose, and what came with it.
Press Step through to watch the breadth-first search fill seen. The real command is npm ls tiny-pad: "why is this here?"
Two kinds of risk. A package can have an honest vulnerability, which you fix by updating. Or the package itself can be malicious: a maintainer's account is stolen, a lookalike name is published (typosquatting), or a trusted project is slowly taken over. A lockfile records the exact version and a cryptographic hash of every package, so what you install tomorrow is byte-for-byte what you reviewed today:
lockfile-check.js
123456789101112131415
// A lockfile pins each package to the exact bytes you reviewed, by hash.
The same idea protects scripts loaded from CDNs: Subresource Integrity, an integrity="sha384-…" attribute on the script tag.
habits for npm (other ecosystems have the same)
# Install exactly what the lockfile says, and check every hashnpmci# Known vulnerabilities in what you depend onnpmaudit# Don't run install scripts from packages you haven't reviewednpmci--ignore-scripts
Habits: commit the lockfile; install with npm ci in CI; let a bot (Dependabot, Renovate) propose updates and read what changed; prefer fewer, well-maintained dependencies; and treat a new dependency like hiring someone who gets the keys. The checks belong in your CI pipeline, on every change.
10 · LEAST PRIVILEGE
Least privilege: limit the blast radius
Assume something will be broken into one day: a dependency, a stolen key, a bug you haven't found. The question is how much the attacker gets. The principle of least privilege says every program, user and service should have only the permissions its job needs, and no more. On the stage, the red sphere is what a hacked server can reach. Trim its grants and the sphere shrinks.
grants.js
12345678910111213141516171819202122
// Least privilege: grant what the job needs, nothing more. Deny by default.
Comparing what is granted with what is actually used is how cloud tools (like AWS IAM Access Analyzer) suggest trimmed policies.
PostgreSQL · a database user that can only read
-- The reporting job's database user can read two tables. That's all.CREATEROLEreport_serviceLOGIN;GRANTSELECTONorders,usersTOreport_service;-- no INSERT, UPDATE, DELETE, no other tables, not the owner, not a superuser
Where it applies: the database user your app connects as (not the owner, never a superuser); cloud roles (no *); API tokens (scoped and short-lived); containers (not root, read-only file system); people (admin rights only when needed). Combined with the earlier acts, it means a SQL injection in a read-only reporting service can't drop tables, and a leaked key can't reach the vault.
11 · HTTPS
HTTPS: protecting data on the move
Between a browser and your server sit Wi-Fi routers, ISPs and networks you don't control (see the internet). With plain HTTP, any of them can read and change everything: passwords, cookies, the page itself. On the stage, the eavesdropper reads the password straight off the pipe. HTTPS is HTTP inside TLS, which gives three things: the data is encrypted, it can't be changed undetected (integrity), and you know you're talking to the real server (authentication, by its certificate).
Here are the first two, with the same algorithms TLS 1.3 uses, built into your browser:
tls-in-miniature.js
1234567891011121314151617181920
// What HTTPS does, with the browser's own crypto (no network involved).
// 1. Key exchange: both sides get the SAME key without ever sending it.
catch(e){console.log("tampered:",e.name,"→ the message is rejected");}
Run it twice: the bytes on the wire change every time (a fresh key and a fresh random IV), but the server always reads the same message. Flip one bit and AES-GCM refuses to decrypt at all.
What a real handshake adds is the third part: the server proves who it is with a certificate signed by an authority your browser trusts, so nobody in the middle can swap in their own key. Watch a whole handshake in 3D on HTTPS, and how the ciphers work on Encryption.
As a programmer: serve everything over HTTPS (free certificates from Let's Encrypt), redirect HTTP to it, send HSTS so browsers never try plain HTTP again, mark cookies Secure, and use TLS between your own services too. Never turn off certificate checks to make an error go away.
HTTP response header
# Tell browsers: only ever use HTTPS for this site, for a yearStrict-Transport-Security:max-age=31536000;includeSubDomains
1 round tripis all a full TLS 1.3 handshake needs before encrypted data flows, down from two in TLS 1.2. Security doesn't have to be slow. RFC 8446, TLS 1.3
12 · LOGGING
Logging without leaking
Logs are how you notice an attack (OWASP's A09 is about not having them) and how you investigate one. But logs are copied to many places, read by many people and kept for a long time, so a password written to a log is a password leaked. On the stage the log panel fills with what careless code writes, then with what careful code writes.
logger.js
1234567891011121314151617181920212223
// Logs are read by many people and kept for a long time. Keep secrets out.
console.log("NAIVE: login request "+JSON.stringify(req));// ✗ the password is now in the log
log("info","login request",req);// ✓
constname="bob\n2026-10-11 INFO admin logged in";// a newline inside user input
console.log("NAIVE: login failed for "+name);// ✗ forges a second line
log("warn","login failed",{user:name});// ✓ stays one line
Two bugs in the naive lines: a password copied into the log, and a newline in a username that forges a fake "admin logged in" line. JSON keeps each entry on one line.
Do log: logins and failed logins, permission denials, input that failed validation, admin actions and changes to permissions, with a timestamp, the user id and a request id. Never log: passwords, session ids and tokens, API keys, full card numbers, or personal data you don't need. Prefer structured logs (one JSON object per line), log fields you chose by name rather than whole request objects, and alert on the patterns that matter, like a burst of failed logins.
PRACTICE
Now you do it
Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.
1
Close the injection
getEmail glues the name into its SQL. Change it to use a ? placeholder so x' OR '1'='1 finds nobody, carol still finds Carol, and a name with a quote in it no longer crashes. The toy engine is at the bottom of the block.
]};// (a toy keeps plain passwords; act 7 shows what real apps store)
}
2
Escape it
Write escapeHtml so any text is shown as text. It must turn & < > " ' into & < > " ', and it must not mangle text that is already safe.
exercise-2.js
123456
// Write escapeHtml so that any text is shown as text, never read as markup.
Anyone logged in can read any invoice by changing the id: an IDOR (insecure direct object reference). Make getInvoice throw unless the session's user owns the invoice or is an admin, and also when the invoice doesn't exist.
// Only the owner, or an admin, may read an invoice. Throw otherwise.
functiongetInvoice(session,id){
if(!session)thrownewError("401: log in first");
constinv=INVOICES.find((x)=>x.id===id);
returninv;
}
console.log(getInvoice({user:"alice"},2));// should throw
4
Fail closed
When the role service fails, this check says yes. Run it: mallory, who isn't in the system at all, may delete users. Fix it so any failure means no. This is OWASP's new A10: mishandling exceptional conditions.
exercise-4.js
12345678910111213141516171819
constROLES={alice:"admin",bob:"customer"};
functionlookupRole(user){
if(!(userinROLES))thrownewError("role service timed out");
returnROLES[user];
}
functioncanDeleteUsers(user){
try{
returnlookupRole(user)==="admin";
}catch(e){
console.log("role lookup failed:",e.message);
returntrue;// "don't lock people out if the role service is down"
}
}
console.log("alice",canDeleteUsers("alice"));
console.log("bob",canDeleteUsers("bob"));
console.log("mallory",canDeleteUsers("mallory"));
5
Check the request
Write isAllowed(req, session) for the bank: allow a request only if it is a POST, its origin is exactly https://bank.example, and its body's csrf token equals the session's. Anything missing means no.
exercise-5.js
123456789
// The bank's server gets a request. Allow it only if it is a POST, it
// comes from the bank's own origin, and its CSRF token matches the session's.
Write redact(obj): return a copy in which the value of every key containing password, token or secret (any case) is "[REDACTED]", at any depth, including inside arrays. Leave everything else as it was.
exercise-6.js
123456789101112
// Replace the value of any key that mentions password, token or secret
// (any case, anywhere in the key) with "[REDACTED]", at any depth.
functionredact(obj){
returnobj;
}
console.log(JSON.stringify(redact({
user:"alice",
password:"correct horse",
session:{apiToken:"abc123",started:1700000000},
tags:["checkout"],
})));
RECALL
Check yourself, from memory
Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.
What are the four questions of threat modelling?
Why does a parameterized query stop SQL injection, when escaping quotes by hand often fails?
XSS and SQL injection are the same mistake. What is it?
Authentication or authorization: which one is missing when a logged-in user can read someone else's invoice?
Name the three things a password hash needs, and why.
You committed an API key and deleted it in the next commit. Are you safe?
GO FURTHER
Resources
DOCS · FREEOWASP Top 10:2025 ↗OWASP FoundationThe list itself: what each risk is, how it happens, and how to prevent it, with the data behind the ranking.
DOCS · FREEOWASP Cheat Sheet Series ↗OWASP FoundationShort, practical pages on exactly one thing each: SQL injection prevention, XSS, CSRF, password storage, logging, secrets.
PRACTICE · FREEPortSwigger Web Security Academy ↗PortSwiggerFree interactive labs: deliberately vulnerable apps, built for you to practise on legally. The best place to train this chapter.
PRACTICE · FREEOWASP Juice Shop ↗OWASP FoundationAn intentionally insecure web shop you run on your own machine and break in over 100 ways, with a scoreboard.
BOOK · FREESecurity Engineering (3rd ed.) ↗Ross AndersonThe classic textbook on building systems that stay dependable under attack. Many chapters free to read.
COURSE · FREECS 253: Web Security ↗Stanford UniversityStanford's web security course: the browser security model, XSS, CSRF, cookies, HTTPS. Slides and videos are online.
ARTICLE · FREEThreat Modeling Manifesto ↗One page of values and principles from the people who practise threat modelling, built around four questions.
TOOL · FREEOpenSSF Scorecard ↗Open Source Security FoundationScores any open-source project on supply-chain habits: pinned dependencies, code review, signed releases, branch protection.