---
title: 'Dynamic Application Security Testing'
source: 'https://academia.sh/en/courses/non-functional-testing/dynamic-application-security-testing'
course: 'Non-Functional Testing'
language: en
updated: '2026-08-23T14:25:17+00:00'
license: 'CC BY-SA 4.0'
---

# Dynamic Application Security Testing

Measuring a test that sends requests to a running service: the share of endpoints reached through link discovery and a route table, threshold scanning applied to the scored findings the probes produce, and the empty intersection between the defect classes static and dynamic testing see.

Static scanning read what the code writes, not what it does. A call sitting on a line
could be on a branch that is never reached; a value embedded in the code could be
overridden in production by an environment variable. The reverse also holds: in a system
where two files are each clean on their own, the combination of their endpoints can meet a
request with a completely unexpected response.

**Dynamic application security testing** therefore looks at the running process rather
than the source: it sends requests to endpoints, reads the responses, and treats the
response itself as evidence. This lesson measures three things: how much of the endpoints
this test can reach, where the threshold that turns evidence into a finding sits, and where
the defect classes the two methods see diverge.

## The Service Under Test

The lending service has eight endpoints. Five defects have been manually verified; the
results will be compared against this list.

```js
// service.mjs — the lending service's eight endpoints; start() returns a listening server
import { createServer } from "node:http";

const BOOK = { "978-1": "The Hourglass", "978-2": "Still on the Shelf" };
const MEMBER = { 1: { name: "A. Fields", penalty: 0 }, 2: { name: "B. Marsh", penalty: 12 } };

export const ENDPOINTS = ["/", "/catalog", "/book", "/status", "/member", "/loan", "/report", "/admin/log"];

// Manually verified defects (endpoint | class).
export const ACTUAL = [
  "/book|input-echo", "/book|empty-input-detail", "/member|unauthenticated-access",
  "/loan|unauthenticated-accept", "/admin/log|internal-detail",
];

export function start() {
  const s = createServer((req, res) => {
    const u = new URL(req.url, "http://x");
    const send = (code, body) => res.writeHead(code, { "content-type": "application/json" })
      .end(JSON.stringify(body));

    if (u.pathname === "/") return send(200, { links: ["/catalog", "/status"] });
    if (u.pathname === "/catalog")
      return send(200, { book: Object.keys(BOOK).map((i) => `/book?isbn=${i}`) });
    if (u.pathname === "/book") {
      const isbn = u.searchParams.get("isbn") ?? "";
      if (isbn === "")                                          // branch that opens only on empty input
        return send(400, { error: "isbn empty", query: "SELECT title FROM book WHERE isbn = ''" });
      return BOOK[isbn] ? send(200, { isbn, title: BOOK[isbn] })
        : send(404, { error: `no such record: ${isbn}` });        // input is echoed back as-is
    }
    if (u.pathname === "/status") return send(200, { status: "open", queue: 0 });
    if (u.pathname.startsWith("/member/")) {
      const no = u.pathname.slice(8);
      return MEMBER[no] ? send(200, { no, ...MEMBER[no] }) : send(404, { error: "no such member" });
    }
    if (u.pathname === "/loan" && req.method === "POST") return send(202, { accepted: true });
    if (u.pathname === "/report") return send(200, { rows: Object.keys(MEMBER).length });
    if (u.pathname === "/admin/log")
      return send(500, { error: "could not read log", path: "/srv/loan/var/log/loan.log" });
    return send(404, { error: "no such path" });
  });
  return s;
}

export const listen = (s) => new Promise((c) => s.listen(0, "127.0.0.1", () => c(s.address().port)));
```

## Endpoint Coverage

Dynamic testing's first limit is the surface it can reach. A discovery run that starts
only from the root endpoint and follows the links found in responses gives a different
result on the same service than a run given a route table extracted from the source.

```js
// discovery.mjs — the share of endpoints dynamic testing can reach: by links alone, and with a route table
import { start, listen, ENDPOINTS, ACTUAL } from "./service.mjs";

const s = start();
const p = await listen(s);
const base = `http://127.0.0.1:${p}`;
const path = (u) => (u.split("?")[0].startsWith("/member/") ? "/member" : u.split("?")[0]);

// (a) Discovery that starts only from "/" and follows the links found in responses.
const seen = new Set(), queue = ["/"];
let requests = 0;
while (queue.length) {
  const u = queue.shift();
  if (seen.has(path(u))) continue;
  seen.add(path(u));
  const body = await (await fetch(base + u)).text();
  requests += 1;
  for (const m of body.matchAll(/"(\/[^"]*)"/g)) if (!seen.has(path(m[1]))) queue.push(m[1]);
}
console.log(`link-following discovery : ${requests} requests, ${seen.size}/${ENDPOINTS.length} endpoints` +
  ` = ${((100 * seen.size) / ENDPOINTS.length).toFixed(0)}%`);
const outside = ENDPOINTS.filter((u) => !seen.has(u));
console.log(`  unreachable            : ${outside.join(", ")}`);
console.log(`  defects sitting on these endpoints: ${ACTUAL.filter((g) => outside.includes(g.split("|")[0])).length}/${ACTUAL.length}`);

// (b) Given a route table extracted from the source, the same run touches every endpoint.
const routes = new Set();
for (const u of ENDPOINTS) {
  const target = u === "/member" ? "/member/1" : u;
  const y = await fetch(base + target, u === "/loan" ? { method: "POST", body: "{}" } : undefined);
  if (y.status !== 404 || u === "/book") routes.add(u);
  await y.text();
}
console.log(`route-table discovery    : ${ENDPOINTS.length} requests, ${routes.size}/${ENDPOINTS.length} endpoints` +
  ` = ${((100 * routes.size) / ENDPOINTS.length).toFixed(0)}%`);
s.close();
```

```
link-following discovery : 4 requests, 4/8 endpoints = 50%
  unreachable            : /member, /loan, /report, /admin/log
  defects sitting on these endpoints: 3/5
route-table discovery    : 8 requests, 8/8 endpoints = 100%
```

Discovery that follows links saw half the endpoints, and the half it saw held only two of
the defects. The four it could not reach are mentioned in no response: one requires a
request body, one sits on the administrative side, and two never appear in any link list.
**The coverage threshold's source here is a measurement:** because the defect count sitting
on uncovered endpoints comes out to 3/5, the rule "a run is not accepted unless it touches
every endpoint" becomes defensible.

The way to meet this threshold is telling: the route table is extracted **from the
source**. What pushes dynamic testing's coverage to the ceiling is a static artifact; the
two methods feed each other here.

## Probes and Threshold Scanning

A single request goes to each endpoint. The response is scored against three rules: does
the harmless marker sent come back verbatim in the body, does a personal field come back
without any identity declared, does an internal detail appear in the body. The scores mean
the same thing as in the previous lesson; the weight comes from **NF8** and continues as
`w = 5`.

```js
// probe.mjs — one-request probes for every endpoint, scored findings and threshold scanning
import { start, listen, ENDPOINTS, ACTUAL } from "./service.mjs";

const MARKER = "ISBN-CHECK-7391";                // harmless marker: searched for in the response
const PERSONAL = ["name", "penalty"], COUNTER = ["queue", "rows"];

const s = start();
const base = `http://127.0.0.1:${await listen(s)}`;
const findings = [];
for (const endpoint of ENDPOINTS) {
  const post = endpoint === "/loan";
  const target = endpoint === "/member" ? "/member/2" : `${endpoint}?isbn=${MARKER}`;
  const y = await fetch(base + target, post ? { method: "POST", body: "{}" } : undefined);
  const body = await y.text();
  if (body.includes(MARKER)) findings.push({ endpoint, class: "input-echo", score: 80 });
  if (y.status < 300 && PERSONAL.some((a) => body.includes(`"${a}"`))) findings.push({ endpoint, class: "unauthenticated-access", score: 90 });
  if (y.status < 300 && post) findings.push({ endpoint, class: "unauthenticated-accept", score: 50 });
  if (/"\/(srv|var|home)\//.test(body)) findings.push({ endpoint, class: "internal-detail", score: 85 });
  else if (COUNTER.some((a) => body.includes(`"${a}"`))) findings.push({ endpoint, class: "internal-counter", score: 40 });
}
s.close();

const key = (b) => `${b.endpoint}|${b.class}`;
console.log(`${ENDPOINTS.length} endpoints, ${ENDPOINTS.length} requests, ${findings.length} findings, ${ACTUAL.length} manually verified defects\n`);
for (const b of findings.sort((a, c) => c.score - a.score)) {
  console.log(`  ${b.endpoint.padEnd(17)}${b.class.padEnd(24)}${String(b.score).padStart(3)}  actual: ${ACTUAL.includes(key(b)) ? "yes" : "no"}`);
}

console.log(`\n${"threshold".padStart(9)}${"remaining".padStart(11)}${"false positive".padStart(16)}${"false negative".padStart(16)}${"cost w=5".padStart(10)}`);
for (const e of [30, 40, 50, 60, 80, 90]) {
  const k = findings.filter((b) => b.score >= e);
  const fp = k.filter((b) => !ACTUAL.includes(key(b))).length;
  const falseNegative = ACTUAL.length - k.filter((b) => ACTUAL.includes(key(b))).length;
  console.log(`${String(e).padStart(9)}${String(k.length).padStart(11)}${String(fp).padStart(16)}${String(falseNegative).padStart(16)}${String(fp + 5 * falseNegative).padStart(10)}`);
}
console.log(`never appears at any threshold: ${ACTUAL.filter((g) => !findings.some((b) => key(b) === g)).join(", ")}`);
```

```
8 endpoints, 8 requests, 6 findings, 5 manually verified defects

  /member          unauthenticated-access   90  actual: yes
  /admin/log       internal-detail          85  actual: yes
  /book            input-echo               80  actual: yes
  /loan            unauthenticated-accept   50  actual: yes
  /status          internal-counter         40  actual: no
  /report          internal-counter         40  actual: no

threshold  remaining  false positive  false negative  cost w=5
       30          6               2               1         7
       40          6               2               1         7
       50          4               0               1         5
       60          3               0               2        10
       80          3               0               2        10
       90          1               0               4        20
never appears at any threshold: /book|empty-input-detail
```

The lowest cost falls at threshold 50: two false positives drop out, four real defects
remain. What differs from the previous lesson is that the findings cluster **discretely**
along the score axis. In static scanning, real and innocent findings sat in the same band;
here the two false positives sit at 40, and the four real defects sit at 50 and above. The
reason is the method's nature: the evidence is the response itself, not a guess at intent.

**The caught classes** are unauthenticated access, unauthenticated accept, input echo, and
internal detail leakage. All four are visible only while running. **The missed class** is
on the last line: the branch that opens only on empty input never appeared at any
threshold, because the probe never sent that input. Dynamic testing sees the paths its
input opens; the input it never sends stays silent in the codebase.

## Where the Two Methods Diverge

What comes out if the previous lesson's three static rules are applied to the same
service?

```js
// divergence.mjs — applying static rules to the same service and the class set each method sees
import { readFileSync } from "node:fs";

const secret = /\b(\w*(?:key|password|token|secret)\w*)\s*=\s*["'][^"']{8,}["']/i;
const RULE = [
  ["forbidden-call", /\beval\s*\(|\bnew\s+Function\s*\(/],
  ["concatenated-query", /(SELECT|INSERT|UPDATE|DELETE)[^"'`]*["'`]\s*\+\s*\w+/i],
  ["hardcoded-secret", secret],
];

const lines = readFileSync(new URL("./service.mjs", import.meta.url), "utf8").split("\n");
const staticFindings = lines.flatMap((s, i) =>
  RULE.filter(([, d]) => d.test(s)).map(([name]) => `service.mjs:${i + 1} ${name}`));
console.log(`service.mjs: ${lines.length} lines, ${RULE.length} rules -> ${staticFindings.length} static findings`);

// NF9: each defect class's visibility to the two methods was classified by hand.
const CLASS = [                                  // [class, static sees, dynamic sees]
  ["concatenated-query", true, false],
  ["hardcoded-secret", true, false],
  ["forbidden-call", true, false],
  ["input-echo", false, true],
  ["unauthenticated-access", false, true],
  ["unauthenticated-accept", false, true],
  ["internal-detail", false, true],
  ["template-literal-query", false, false],
  ["empty-input-detail", false, false],
];

const count = (s, d) => CLASS.filter(([, a, b]) => a === s && b === d).length;
console.log(`\n${CLASS.length} defect classes:`);
console.log(`  static only  : ${count(true, false)}  (${CLASS.filter(([, a, b]) => a && !b).map(([n]) => n).join(", ")})`);
console.log(`  dynamic only : ${count(false, true)}  (${CLASS.filter(([, a, b]) => !a && b).map(([n]) => n).join(", ")})`);
console.log(`  both         : ${count(true, true)}`);
console.log(`  neither      : ${count(false, false)}  (${CLASS.filter(([, a, b]) => !a && !b).map(([n]) => n).join(", ")})`);
const union = CLASS.filter(([, a, b]) => a || b).length;
console.log(`combined coverage: ${union}/${CLASS.length} = ${((100 * union) / CLASS.length).toFixed(0)}%`);
```

```
service.mjs: 46 lines, 3 rules -> 0 static findings

9 defect classes:
  static only  : 3  (concatenated-query, hardcoded-secret, forbidden-call)
  dynamic only : 4  (input-echo, unauthenticated-access, unauthenticated-accept, internal-detail)
  both         : 0
  neither      : 2  (template-literal-query, empty-input-detail)
combined coverage: 7/9 = 78%
```

The static rules produced not a single finding in this file; the dynamic probes found four
real defects in the same file. The reverse direction is just as sharp: the embedded value
and the concatenated query in the previous lesson's codebase are invisible to any request
made here. In this setup the two methods' **intersection is empty**, and the combined
coverage is seven of nine classes. As the rule sets grow the intersection grows too, but
neither substitutes for the other.

**Who owns the decision:** a finding above threshold 50 stops the release; a run that fails
to meet the coverage threshold reports no result and runs again. A dynamic testing report
that does not state its coverage hides how many endpoints were never tried.

## The Cost of Testing

The run-independent cost lives in three places. The first is request count: 8 endpoints, 8
requests per run, and one process staying up. Static scanning ran with zero processes;
dynamic testing demands the application itself, its data, and its dependencies.

The second is maintaining the route table: what raises coverage from 50% to 100% is an
eight-line list extracted from the source, and when a new endpoint is added and this list
is not updated by hand, coverage silently drops.

The third is the input set: probes see only the branches the value they send opens. The
empty-input branch was missed because a single value was missing. Growing the input set
multiplies the request count by the endpoint count; coverage grows linearly while cost
grows multiplicatively.

## Summary

- Dynamic application security testing sends requests to the running process and takes its
  evidence from the response itself; in this lesson, 8 requests to 8 endpoints produced 6
  findings, 4 of them real defects.
- Link-following discovery reached 50% of the endpoints, and 3 of the 5 manually verified
  defects remained on unreachable endpoints; given a route table, coverage rose to 100%.
- Threshold scanning gave the lowest cost at 50: 0 false fails, 1 false pass. Because the
  findings cluster discretely along the score axis, choosing the threshold is easier here
  than in static scanning.
- The caught classes are the ones visible only while running; the missed class is the
  branch opened by input the probe never sends.
- Three static rules applied to the same service gave zero findings; across nine defect
  classes the two methods' intersection came out empty, and combined coverage stayed at
  78%.
- The cost: one process staying up, 8 requests per run, a hand-maintained route table, and
  a request count that grows multiplicatively as the input set grows.

## Next Step

Both scans tested code that was **written**: one read its text, the other observed its
behavior while running. Yet most of the lines the lending service executes were not
written by this team. The catalog schema depends on a validator, the notification channel
on a queue client, the log writer on a formatter, and each of these brings its own
dependencies. In this code, searching for defects is not a matter of scanning but of
**searching for known vulnerabilities**: which defect is reported for which version of a
component is on record. The next lesson extracts the lending system's dependency tree,
matches its own advisory set against that tree, and measures three things: the share of
dependencies that were never written directly, the false positives produced by
version-range matching, and how many packages a single fix touches.
