@clear-capabilities/agentic-security-scanner 0.137.1 → 0.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/dist/113.index.js +2 -2
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/435.index.js +29 -1
  6. package/dist/526.index.js +2 -2
  7. package/dist/637.index.js +1 -1
  8. package/dist/agentic-security.mjs +14 -14
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +10 -6
  11. package/src/dataflow/CLAUDE.md +30 -0
  12. package/src/dataflow/catalog.js +512 -14
  13. package/src/dataflow/engine.js +275 -27
  14. package/src/dataflow/summaries.js +30 -5
  15. package/src/engine.js +512 -120
  16. package/src/ir/CLAUDE.md +20 -5
  17. package/src/ir/balanced-call.js +11 -1
  18. package/src/ir/callgraph.js +34 -0
  19. package/src/ir/parser-cs.js +55 -6
  20. package/src/ir/parser-go.js +106 -2
  21. package/src/ir/parser-java.js +111 -10
  22. package/src/ir/parser-js.js +40 -0
  23. package/src/ir/parser-kt.js +194 -10
  24. package/src/ir/parser-php.js +108 -6
  25. package/src/ir/parser-py.helper.py +199 -10
  26. package/src/ir/parser-rb.js +405 -31
  27. package/src/mcp/tools.js +29 -1
  28. package/src/posture/accuracy-scorecard.js +103 -0
  29. package/src/runScan.js +5 -2
  30. package/src/sast/CLAUDE.md +1 -1
  31. package/src/sast/_auth-signals.js +141 -0
  32. package/src/sast/_comment-strip.js +80 -13
  33. package/src/sast/codegen-sink.js +110 -0
  34. package/src/sast/convention-deviation.js +235 -0
  35. package/src/sast/fastapi-hardening.js +45 -6
  36. package/src/sast/file-upload.js +29 -1
  37. package/src/sast/ownership-authz.js +245 -0
  38. package/src/sast/php.js +12 -2
  39. package/src/sast/rate-limit.js +2 -0
  40. package/src/sast/rbac-consistency.js +1 -1
  41. package/src/sast/redirect-toctou.js +167 -0
  42. package/src/sast/resource-exhaustion.js +217 -0
  43. package/src/sast/sibling-guard.js +176 -0
  44. package/src/sast/zip-slip.js +53 -2
@@ -65,6 +65,24 @@ function sliceBy(scored, key) {
65
65
  return [...map.values()].sort((a, b) => a.key.localeCompare(b.key));
66
66
  }
67
67
 
68
+ /**
69
+ * Build the { byLanguage: [{language, taint:{n,d}}] } shape for one
70
+ * taintByLanguage/totalByLanguage pair. Sorted by language so the rendered
71
+ * document is stable run-to-run, matching every other byX array here.
72
+ *
73
+ * A language present in `total` but absent from `taint` reports n:0 — a real
74
+ * measured zero. A language absent from BOTH is simply not included in the
75
+ * output at all, so "measured zero" and "not measured on this subset" never
76
+ * collapse into the same row (see the deepTierOnly caller below).
77
+ */
78
+ function taintRateByLanguage(taintByLanguage, totalByLanguage) {
79
+ const langs = Object.keys(totalByLanguage || {}).sort();
80
+ return langs.map(language => ({
81
+ language,
82
+ taint: { n: (taintByLanguage || {})[language] || 0, d: totalByLanguage[language] },
83
+ }));
84
+ }
85
+
68
86
  /**
69
87
  * Aggregate the per-entry detail records emitted by the corpus runner.
70
88
  *
@@ -141,6 +159,24 @@ export function buildScorecard(inputs) {
141
159
  byTier: corpus.byTier,
142
160
  },
143
161
  selfScan: { measuredThisRun: true, targets, polyglot: selfScan.polyglot || { total: 0, byLanguage: {} } },
162
+ taintRecall: (() => {
163
+ const lr = inputs.layerRecall;
164
+ if (!lr) {
165
+ return { measuredThisRun: false, wholeCorpus: { entriesScored: 0, byLanguage: [] }, deepTierOnly: { entriesScored: 0, byLanguage: [] } };
166
+ }
167
+ const deep = lr.deepTier || { entriesScored: 0, taintByLanguage: {}, totalByLanguage: {} };
168
+ return {
169
+ measuredThisRun: true,
170
+ wholeCorpus: {
171
+ entriesScored: lr.entriesScored || 0,
172
+ byLanguage: taintRateByLanguage(lr.taintByLanguage, lr.totalByLanguage),
173
+ },
174
+ deepTierOnly: {
175
+ entriesScored: deep.entriesScored || 0,
176
+ byLanguage: taintRateByLanguage(deep.taintByLanguage, deep.totalByLanguage),
177
+ },
178
+ };
179
+ })(),
144
180
  committedInputs: {
145
181
  corpusBaseline: committed.corpusBaseline
146
182
  ? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
@@ -279,6 +315,73 @@ export function renderScorecardMarkdown(m) {
279
315
  L.push('| --- | --- | --- | --- |');
280
316
  for (const r of c.byTier) L.push(rateRow(r));
281
317
  L.push('');
318
+ L.push('## Taint-layer recall by language');
319
+ L.push('');
320
+ L.push('Two views of the same layer-recall instrument, because reporting only the');
321
+ L.push('first would silently overstate taint capability and reporting only the');
322
+ L.push('second would understate coverage of what the corpus actually contains:');
323
+ L.push('');
324
+ L.push('- **Whole corpus** — diagnostic only. The large majority of this corpus is');
325
+ L.push(' caught by the pattern/structural layers without needing taint at all, so a language\'s');
326
+ L.push(' rate here is diluted by every entry that never exercised the taint');
327
+ L.push(' engine. A language reading near-zero here is not necessarily a taint');
328
+ L.push(' defect — see `docs/METRICS.md`.');
329
+ L.push('- **Deep-tier only (the taint-shaped subset)** — the number to quote for');
330
+ L.push(' taint capability. Every entry in this bucket is required, before it can');
331
+ L.push(' be committed, to be provably invisible with the deep engine off and');
332
+ L.push(' detected with it on (`bench/cve-replay/CONTRIBUTING.md`, "deep/" tier).');
333
+ L.push(' A language absent from this table has no deep-tier entry yet — that is');
334
+ L.push(' "not yet measured", never "zero capability".');
335
+ L.push('');
336
+ L.push('**No taint-specific precision percentage is reported here, deliberately —');
337
+ L.push('same reasoning as the corpus-wide F1 omission above.** A precision figure');
338
+ L.push('needs a labelled population containing both true and false positives; this');
339
+ L.push('section\'s denominator is all-vulnerable by construction (`pre/` fixtures),');
340
+ L.push('so it cannot supply one. The false-positive side is instrumented instead as');
341
+ L.push('a gate, not a rate: `bench/self-scan/fixtures/polyglot/` carries one');
342
+ L.push('untainted, negative-control fixture per language, covering eight of the');
343
+ L.push('nine first-class languages (C/C++ has no fixture in this set yet), and');
344
+ L.push('`bench:self-scan:check`\'s existing exact per-file drift gate fails the');
345
+ L.push('build the moment any of them stops reading zero. See the self-scan section');
346
+ L.push('below for current counts.');
347
+ L.push('');
348
+ L.push('### Whole corpus (diagnostic)');
349
+ L.push('');
350
+ L.push(`Entries scored: ${m.taintRecall.wholeCorpus.entriesScored}`);
351
+ L.push('');
352
+ L.push('| Language | IR-TAINT recall |');
353
+ L.push('| --- | --- |');
354
+ for (const r of m.taintRecall.wholeCorpus.byLanguage) {
355
+ L.push(`| ${r.language} | ${formatRate(r.taint.n, r.taint.d)} |`);
356
+ }
357
+ L.push('');
358
+ L.push('### Deep-tier only — taint-shaped subset (headline)');
359
+ L.push('');
360
+ L.push(`Entries scored: ${m.taintRecall.deepTierOnly.entriesScored}`);
361
+ L.push('');
362
+ if (m.taintRecall.deepTierOnly.byLanguage.length) {
363
+ L.push('| Language | IR-TAINT recall |');
364
+ L.push('| --- | --- |');
365
+ for (const r of m.taintRecall.deepTierOnly.byLanguage) {
366
+ L.push(`| ${r.language} | ${formatRate(r.taint.n, r.taint.d)} |`);
367
+ }
368
+ } else {
369
+ L.push('No deep-tier entries scored this run.');
370
+ }
371
+ L.push('');
372
+ // INTEGRITY CONTRACT above requires unmeasured things "disclosed by name",
373
+ // not just described generically — so name the languages that are present
374
+ // in the whole-corpus view but have no deep-tier entry at all, rather than
375
+ // leaving that as an unnamed implication of the two tables above.
376
+ const deepLangs = new Set(m.taintRecall.deepTierOnly.byLanguage.map(r => r.language));
377
+ const notYetMeasured = m.taintRecall.wholeCorpus.byLanguage
378
+ .map(r => r.language)
379
+ .filter(l => !deepLangs.has(l))
380
+ .sort();
381
+ if (notYetMeasured.length) {
382
+ L.push(`Not yet measured on this subset: ${notYetMeasured.join(', ')}`);
383
+ L.push('');
384
+ }
282
385
  L.push('## Precision-side signal: self-scan (measured this run)');
283
386
  L.push('');
284
387
  L.push('The engine scanned its own repository. These are absolute finding');
package/src/runScan.js CHANGED
@@ -4,7 +4,7 @@ import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
5
  import * as cp from 'node:child_process';
6
6
  import { listFiles } from './util/glob.js';
7
- import { runFullScan, shouldScan } from './engine.js';
7
+ import { runFullScan, shouldScan, isKubernetesManifest } from './engine.js';
8
8
  import { appendScanSnapshot } from './posture/security-trend.js';
9
9
  import { recover as recoverFixHistory } from './posture/fix-history.js';
10
10
  import { stampScan } from './posture/ruleset-version.js';
@@ -45,7 +45,10 @@ export async function readTree(root, { ignore = [] } = {}) {
45
45
  else if (/\.proto$/i.test(base)) depFileContents[rel] = content;
46
46
  else if (/\.(?:graphql|gql)$/i.test(base)) depFileContents[rel] = content;
47
47
  else if (/\.tf$/i.test(base)) depFileContents[rel] = content;
48
- if (shouldScan(rel)) fileContents[rel] = content;
48
+ // A Kubernetes manifest is admitted on CONTENT, not on living under a
49
+ // directory named k8s/ — see isKubernetesManifest. Without this the
50
+ // k8s-admission detector is wired into the dispatch and never invoked by it.
51
+ if (shouldScan(rel) || isKubernetesManifest(rel, content)) fileContents[rel] = content;
49
52
  // Auxiliary files: .properties files are referenced by Java rules
50
53
  // (e.g. OWASP Benchmark's benchmark.properties resolves algorithm
51
54
  // aliases). They are not scannable for vulns themselves, but the
@@ -43,7 +43,7 @@ Both key on the SECRET-NESS of the identifier, never on the comparison or the ty
43
43
 
44
44
  ## Gotchas
45
45
 
46
- - **Comments confuse detectors.** Always go through `blankComments()` from `_comment-strip.js` before scanning a file body.
46
+ - **Comments confuse detectors — now handled centrally, but read this before relying on it.** `runFullScan` (`../engine.js`) computes ONE comment-blanked, line-and-offset-preserving view per file (`cc`, via `blankComments` + `_commentLangFor`) and passes it to the SAST dispatch, so a new module gets comment-blindness by default and does **not** need its own `blankComments()` call. Calling it anyway is harmless (idempotent) and still correct for modules invoked outside that dispatch. Two things to know: (1) **~20 detectors deliberately receive RAW source** — the LLM/agent/prompt-injection family (instructions hidden in a comment are the attack, not a false positive), the two secrets scanners (a committed credential is leaked whether or not its line executes), and `scanTodosNearSecurity` (comments are its subject). If your rule belongs in that group, say so explicitly at the call site. (2) **Language-awareness is not optional**: `#` is a comment in Python/Ruby/shell but `#include`/`#region` in the C family, and `//` is a comment in C-family languages but **floor division in Python** — blanking it there deletes real code. `_commentLangFor()` owns that mapping; extend it rather than hand-rolling a regex. The property is pinned end-to-end by `test/comment-blindness.test.js` (including a positive control and a Python-floor-division guard), so a regression shows up as a test failure rather than as quietly inflated findings.
47
47
  - **Detector snippet attribution.** Don't grab `lines[matchLine - 1]` blindly — for multi-line patterns (`exec('ping' + req.body.host, …)`), the match index and the readable sink line can diverge. Premortem item — fixed in the dataflow engine; if you're regressing on it, your fix probably needs to use the actual sink expression, not the regex's match offset.
48
48
  - **Severity floor.** No detector should emit `severity: 'critical'` without strong evidence; the calibrator amplifies critical-rated findings and a flood drowns real ones. If in doubt, emit `high` and let `annotateExploitability` push it up.
49
49
  - **Family field for calibration.** If you can't tell what family your rule belongs to, that's a signal the rule covers too much. Split it.
@@ -0,0 +1,141 @@
1
+ // Shared cross-framework "is this handler actually protected?" resolver.
2
+ //
3
+ // WHY THIS EXISTS (PRD Theme 1, T1.2). Every authz-adjacent detector used to
4
+ // hand-roll its own list of blessed middleware/dependency names, and each list
5
+ // was a closed enumeration. Real codebases name their auth helpers whatever
6
+ // they like, so the enumerations missed, and the detectors emitted findings
7
+ // asserting an ABSENCE that the same file visibly contradicts.
8
+ //
9
+ // The measured cost of that, from the 2026-08-17 independent-population audit:
10
+ // GHSA-3cg5-48j3-v4gv scored as a TRUE POSITIVE for "FastAPI mutating endpoint
11
+ // create_folder() has no Security() / Depends() auth dependency" against a
12
+ // function whose signature reads `user=Depends(get_verified_user)` and whose
13
+ // first statement is `await check_folders_permission(request, user, db=db)`.
14
+ // The claim was false about the code; it scored only because the CWE and file
15
+ // happened to line up with an unrelated fix 200 lines away. Two more entries in
16
+ // the same population failed the same way.
17
+ //
18
+ // The rule this module encodes: an absence-claim must be checked against BOTH
19
+ // the handler's injected dependencies AND its body. A detector that looks only
20
+ // at a signature cannot see `check_folders_permission(...)`, and one that looks
21
+ // only for known names cannot see a project's own convention.
22
+ //
23
+ // Deliberately biased toward RECOGNISING auth. A false "this is protected"
24
+ // costs one missed finding; a false "this is unprotected" is a wrong statement
25
+ // about code the reader can see, which is what destroys trust in every other
26
+ // finding the tool emits.
27
+
28
+ /**
29
+ * Security NOUNS — the concepts that make an identifier about authentication
30
+ * or authorization. Matched against a CALLEE or PARAMETER name, never against
31
+ * free text, so a comment or string literal cannot satisfy it.
32
+ *
33
+ * Deliberately nouns, not action verbs. An earlier draft included bare
34
+ * `session`, `access`, `verify` and `require`, and its own test immediately
35
+ * caught the consequence: `Depends(get_async_session)` — a DATABASE session —
36
+ * read as authentication evidence and would have silently suppressed real
37
+ * missing-auth findings. Over-suppression is the dangerous direction for this
38
+ * module (it hides vulnerabilities), so ambiguous stems are excluded and only
39
+ * their unambiguous compounds are listed. Verbs are handled separately in
40
+ * hasAuthInBody, where they must co-occur with one of these nouns.
41
+ */
42
+ const AUTH_NAME_RE = new RegExp([
43
+ 'auth', 'authn', 'authz', 'authoriz', 'authoris',
44
+ 'login', 'logged_?in', 'jwt', 'oauth', 'oidc', 'saml',
45
+ 'principal', 'identity', 'credential', 'password',
46
+ 'user', // covers current_/verified_/active_/request_user
47
+ 'permission', 'privilege', 'role', 'scope',
48
+ 'admin', 'superuser', 'staff',
49
+ 'api_?key', 'apikey', 'bearer', 'access_?token', 'access_?control',
50
+ '(?:user|auth|login|web|http)_?session', 'session_?(?:user|token|id)',
51
+ ].join('|'), 'i');
52
+
53
+ /**
54
+ * A dependency-injection wrapper: FastAPI Depends()/Security(), NestJS, etc.
55
+ * Group 1 is the wrapper, group 2 the injected callee (absent for `Security()`).
56
+ */
57
+ const DI_CALL_RE = /\b(Depends|Security)\s{0,8}\(\s{0,8}([A-Za-z_$][\w$.]{0,128})?/g;
58
+
59
+ /**
60
+ * Does a handler's PARAMETER LIST carry injected auth?
61
+ *
62
+ * Recognises the DI shape first (`Depends(x)` / `Security(x)` with an
63
+ * auth-shaped callee), then falls back to an auth-shaped parameter NAME —
64
+ * `user`, `current_user`, `principal` — which is how most frameworks surface
65
+ * an already-resolved identity.
66
+ */
67
+ export function hasAuthInParams(paramsText) {
68
+ const params = String(paramsText || '');
69
+ if (!params.trim()) return null;
70
+
71
+ DI_CALL_RE.lastIndex = 0;
72
+ let m;
73
+ while ((m = DI_CALL_RE.exec(params))) {
74
+ const wrapper = m[1];
75
+ const callee = m[2] || '';
76
+ // `Security(...)` is an auth construct by definition, whatever it wraps —
77
+ // FastAPI has no non-security use for it. `Depends(...)` is generic
78
+ // dependency injection (DB sessions, config, pagination), so it only
79
+ // counts when the injected callee itself names a security concept.
80
+ if (wrapper === 'Security' || AUTH_NAME_RE.test(callee)) {
81
+ return { authenticated: true, reason: `dependency-injected auth: ${wrapper}(${callee || '…'})` };
82
+ }
83
+ }
84
+
85
+ // Parameter name shapes: `user: User`, `user=Depends(...)`, `current_user`.
86
+ for (const p of params.split(',')) {
87
+ const name = (p.split(/[:=]/)[0] || '').trim().replace(/^\*+/, '');
88
+ if (!name) continue;
89
+ if (/^(?:user|current_user|principal|identity|viewer|actor|me)$/i.test(name)) {
90
+ return { authenticated: true, reason: `auth-shaped handler parameter: ${name}` };
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Does a handler BODY perform an explicit authorization check?
98
+ *
99
+ * This is the half the FastAPI detector was missing entirely. A project's own
100
+ * `check_folders_permission(...)` is not in anyone's enumeration of blessed
101
+ * names, but it is unmistakably an authorization call at the call site.
102
+ */
103
+ export function hasAuthInBody(bodyText) {
104
+ const body = String(bodyText || '');
105
+ if (!body.trim()) return null;
106
+ // Call shape: <name>(...) where the callee name is auth-shaped AND reads as
107
+ // an action (check/require/verify/ensure/assert/validate/has/can/enforce).
108
+ // Bounded repetition throughout: this walks third-party source, so a
109
+ // pathological identifier chain must not become a scanner-side ReDoS.
110
+ // Only the FINAL segment is used, so the dotted qualifier is not matched at
111
+ // all — which also keeps this free of the nested quantifier the project's own
112
+ // redos-nfa.js correctly flags on `(?:\w+\.)*` shapes.
113
+ const callRe = /\b([A-Za-z_$][\w$]{0,63})\s{0,8}\(/g;
114
+ let m;
115
+ while ((m = callRe.exec(body))) {
116
+ const callee = m[1];
117
+ const actiony = /^(?:check|require|ensure|verify|assert|validate|enforce|has|can|is|get|authorize|authorise|guard)/i.test(callee);
118
+ if (actiony && AUTH_NAME_RE.test(callee)) {
119
+ return { authenticated: true, reason: `explicit authorization call in body: ${callee}()` };
120
+ }
121
+ }
122
+ // Raise-on-forbidden shape: an explicit 401/403 the handler itself emits.
123
+ if (/\b(?:HTTP_401_UNAUTHORIZED|HTTP_403_FORBIDDEN|UnauthorizedError|ForbiddenError|status_code\s*=\s*40[13]|\b40[13]\b\s*,)/.test(body)) {
124
+ return { authenticated: true, reason: 'handler raises an explicit 401/403' };
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * The single question every authz detector should ask before asserting that a
131
+ * handler is unprotected. Returns null when there is no evidence (i.e. the
132
+ * detector may fire), or {authenticated:true, reason} when it must not.
133
+ *
134
+ * `reason` exists so the finding — or its absence — is explainable, per the
135
+ * PRD's T2.2 requirement that an absence-claim record what it looked for.
136
+ */
137
+ export function routeAuthEvidence({ params = '', body = '' } = {}) {
138
+ return hasAuthInParams(params) || hasAuthInBody(body) || null;
139
+ }
140
+
141
+ export const _internals = { AUTH_NAME_RE, hasAuthInParams, hasAuthInBody };
@@ -15,43 +15,110 @@
15
15
  //
16
16
  // Skips comment-like content inside string literals (single/double/backtick).
17
17
  //
18
+ // - Ruby: `#` line comments PLUS `=begin` / `=end` block comments, which
19
+ // must start at column 0. No other supported language has this form, so a
20
+ // `#`-only pass leaves the whole block intact and every dangerous
21
+ // construct inside it reads as live code.
22
+ //
18
23
  // The `lang` parameter is optional; pass 'py' to treat `#` as a line comment
19
- // (and skip `//`/`/* */`), or 'php' to strip all three comment forms.
24
+ // (and skip `//`/`/* */`), 'rb' for Python's `#` handling plus Ruby's
25
+ // `=begin`/`=end` blocks, or 'php' to strip all three comment forms.
20
26
 
27
+ // PERFORMANCE. This runs on every scanned file, and `engine.js` calls it
28
+ // through `stripNoise` roughly fifteen times per file, so it sits directly on
29
+ // the hot path. The original implementation appended ONE CHARACTER AT A TIME
30
+ // (`out += c`) for the whole file, which measured a 19.7% end-to-end scan
31
+ // regression on a 307-file entry once it replaced the old native-regex
32
+ // stripper.
33
+ //
34
+ // This version keeps the identical state machine — it must still inspect every
35
+ // character to know whether a `//` sits inside a string literal — but emits
36
+ // output in BULK: untouched code is pushed as a single `slice()` of the input,
37
+ // and only the comment runs (a small minority of any real file) are rewritten,
38
+ // via a native regex for the newline-preserving cases. Characters are scanned;
39
+ // they are no longer individually concatenated.
21
40
  export function blankComments(s, lang) {
22
- let out = '';
41
+ const parts = [];
42
+ let verbatimFrom = 0; // start of the run of input not yet emitted as-is
23
43
  let inS = null;
24
44
  let i = 0;
25
- const isPy = lang === 'py';
45
+ const isRb = lang === 'rb';
46
+ const isPy = lang === 'py' || isRb;
26
47
  const isPhp = lang === 'php';
27
48
  const stripSlashForms = !isPy || isPhp;
28
49
  const stripHash = isPy || isPhp;
50
+ // Flush the pending verbatim run, then blank [start, end) with newlines kept
51
+ // so every byte offset and line number in the output still matches the input.
52
+ const blank = (start, end) => {
53
+ if (start > verbatimFrom) parts.push(s.slice(verbatimFrom, start));
54
+ parts.push(s.slice(start, end).replace(/[^\n]/g, ' '));
55
+ verbatimFrom = end;
56
+ };
29
57
  while (i < s.length) {
30
58
  const c = s[i];
31
59
  if (inS) {
32
- out += c;
33
- if (c === '\\' && i + 1 < s.length) { out += s[i+1]; i += 2; continue; }
60
+ // A `'` or `"` string cannot span a line in any language handled here, so
61
+ // a newline ends it. Without this reset an ODD number of quotes on one
62
+ // line — which regex literals produce routinely, e.g.
63
+ // `/"(?:sh|bash)"\s*,\s*(?!"[^"]*")/` — leaves the scanner stuck in
64
+ // string mode for the WHOLE REST OF THE FILE, silently disabling comment
65
+ // stripping from that point on. Measured on this repo's own
66
+ // `sast/go-extended.js`, where it resurrected a false positive out of a
67
+ // comment six lines below the regex.
68
+ //
69
+ // Backticks are exempt: JS template literals and Go raw strings are
70
+ // genuinely multi-line. Erring toward "this is code" is the safe
71
+ // direction — the failure it causes is a stripped comment, whereas the
72
+ // opposite error hides every subsequent comment in the file.
73
+ if (c === '\n' && inS !== '`') { inS = null; i++; continue; }
74
+ if (c === '\\' && i + 1 < s.length) { i += 2; continue; }
34
75
  if (c === inS) inS = null;
35
76
  i++; continue;
36
77
  }
37
- if (c === "'" || c === '"' || c === '`') { inS = c; out += c; i++; continue; }
78
+ // Ruby `=begin` `=end`: only recognised at the start of a line, which is
79
+ // what makes it distinguishable from an ordinary `=` assignment.
80
+ if (isRb && c === '=' && (i === 0 || s[i - 1] === '\n') && s.startsWith('=begin', i)) {
81
+ let end = i;
82
+ for (;;) {
83
+ const nl = s.indexOf('\n', end);
84
+ if (nl < 0) { end = s.length; break; }
85
+ if (s.startsWith('=end', nl + 1)) {
86
+ const after = s.indexOf('\n', nl + 1);
87
+ end = after < 0 ? s.length : after;
88
+ break;
89
+ }
90
+ end = nl + 1;
91
+ }
92
+ blank(i, end);
93
+ i = end;
94
+ continue;
95
+ }
96
+ if (c === "'" || c === '"' || c === '`') { inS = c; i++; continue; }
38
97
  if (stripSlashForms && c === '/' && s[i+1] === '/') {
39
- while (i < s.length && s[i] !== '\n') { out += ' '; i++; }
98
+ const nl = s.indexOf('\n', i);
99
+ const end = nl < 0 ? s.length : nl;
100
+ blank(i, end);
101
+ i = end;
40
102
  continue;
41
103
  }
42
104
  if (stripSlashForms && c === '/' && s[i+1] === '*') {
43
- const end = s.indexOf('*/', i + 2);
44
- const stop = end < 0 ? s.length : end + 2;
45
- while (i < stop) { out += (s[i] === '\n' ? '\n' : ' '); i++; }
105
+ const close = s.indexOf('*/', i + 2);
106
+ const end = close < 0 ? s.length : close + 2;
107
+ blank(i, end);
108
+ i = end;
46
109
  continue;
47
110
  }
48
111
  // PHP 8 attributes (`#[Route(...)]`) use the same `#` prefix as a line
49
112
  // comment — `#[` is never a comment, so don't blank it.
50
113
  if (stripHash && c === '#' && s[i+1] !== '[') {
51
- while (i < s.length && s[i] !== '\n') { out += ' '; i++; }
114
+ const nl = s.indexOf('\n', i);
115
+ const end = nl < 0 ? s.length : nl;
116
+ blank(i, end);
117
+ i = end;
52
118
  continue;
53
119
  }
54
- out += c; i++;
120
+ i++;
55
121
  }
56
- return out;
122
+ if (verbatimFrom < s.length) parts.push(s.slice(verbatimFrom));
123
+ return parts.join('');
57
124
  }
@@ -0,0 +1,110 @@
1
+ // PRD T4.3 — code generation as an injection sink. 5 of the 96 root-caused
2
+ // real-world misses.
3
+ //
4
+ // A NEW SINK CATEGORY, not a new sink entry. Every injection rule in this
5
+ // codebase asks "does untrusted data reach a dangerous CALL" — eval, exec,
6
+ // a query, a shell. Here there is no dangerous call at all: the program
7
+ // WRITES A FILE, and the file happens to be source code that something else
8
+ // imports and runs later. The execution is in a different process, often on a
9
+ // different machine, minutes or months afterwards.
10
+ //
11
+ // The real entries, all in code generators:
12
+ // - a JSON-Schema `x-python-type` / `default_factory` / `customTypePath`
13
+ // spliced verbatim into a generated .py type annotation or import
14
+ // (GHSA-m34r-v34r-rf9q, GHSA-5578-w22f-pfx9, GHSA-386q-5hp3-95m9)
15
+ // - field/mode strings interpolated into generated validator source
16
+ // (GHSA-8m8r-38jm-f355)
17
+ // - an `--extra-template-data` comment field unescaped for \r, so a
18
+ // generated source COMMENT can hide executable statements from a reviewer
19
+ // (GHSA-wjv6-jcfj-mf9r)
20
+ //
21
+ // Precision: a template engine writing HTML is not this. The rule requires
22
+ // BOTH that the written artifact is source code (a code-ish extension, or a
23
+ // clearly code-shaped template) AND that an interpolated value comes from a
24
+ // parsed document / config surface rather than a literal — and it stays silent
25
+ // when the value passes through an identifier validator, which is what every
26
+ // one of these advisories added as its fix.
27
+ import { blankComments } from './_comment-strip.js';
28
+
29
+ const SRC_RE = /\.(?:py|js|jsx|ts|tsx|mjs|cjs)$/i;
30
+
31
+ /** Writing a file whose name looks like source code. */
32
+ const CODE_WRITE_RE = new RegExp([
33
+ // open("out.py", "w") / Path(...).write_text(...) / fs.writeFile("x.ts", ...)
34
+ 'open\\s{0,4}\\([^)]{0,200}\\.(?:py|js|ts|rb|go|java|php|sh)["\\\']',
35
+ 'write_text\\s{0,4}\\(', 'writeFileSync?\\s{0,4}\\([^)]{0,200}\\.(?:py|js|ts|mjs|cjs)["\\\']',
36
+ '\\.write\\s{0,4}\\(',
37
+ ].join('|'), 'i');
38
+
39
+ /** The generated artifact is program text: an import, def, class, or assignment. */
40
+ const CODE_SHAPED_RE = /(?:^|["'`\s])(?:import\s|from\s+\w|def\s|class\s|return\s|lambda\s|=\s*lambda|@\w)/;
41
+
42
+ /** Interpolation of a non-literal into that text. */
43
+ const INTERPOLATION_RE = /(?:f["'][^"']{0,200}\{|\$\{|%\s{0,2}\(|\.format\s{0,4}\(|\+\s{0,4}\w|Template\s{0,4}\(|render\w{0,10}\s{0,4}\()/;
44
+
45
+ /** Values that come from a parsed document / caller config, not from source. */
46
+ const EXTERNAL_FIELD_RE =
47
+ /\b(?:extras?|schema|spec|definition|properties|metadata|template_?data|extra_?template|custom\w{0,20}|x-[\w-]{1,30}|config|options|field_?name|user_\w{1,20})\b/i;
48
+
49
+ /** The mitigation every one of these advisories shipped. */
50
+ const IDENTIFIER_VALIDATION_RE = new RegExp([
51
+ 'isidentifier\\s{0,4}\\(', 'str\\.isidentifier',
52
+ 'VALID\\w{0,20}_RE', '_RE\\.(?:match|fullmatch)\\s{0,4}\\(',
53
+ 're\\.(?:match|fullmatch)\\s{0,4}\\(', 'allow_?list', 'ALLOWED_\\w{1,30}',
54
+ 'sanitiz\\w{0,6}\\s{0,4}\\(', 'escape\\w{0,10}\\s{0,4}\\(',
55
+ 'validate_\\w{1,30}\\s{0,4}\\(', 'is_?safe\\w{0,20}\\s{0,4}\\(',
56
+ ].join('|'), 'i');
57
+
58
+ const _lineOf = (raw, i) => raw.slice(0, i).split('\n').length;
59
+ const _win = (raw, line, half = 14) => {
60
+ const l = raw.split('\n');
61
+ return l.slice(Math.max(0, line - 1 - half), Math.min(l.length, line - 1 + half)).join('\n');
62
+ };
63
+
64
+ export function scanCodegenSink(file, raw) {
65
+ if (!raw || typeof raw !== 'string' || raw.length > 500_000) return [];
66
+ if (!SRC_RE.test(file)) return [];
67
+ // Cheap relevance gate: only files that both emit and interpolate.
68
+ if (!CODE_WRITE_RE.test(raw) || !EXTERNAL_FIELD_RE.test(raw)) return [];
69
+ const code = blankComments(raw, /\.py$/i.test(file) ? 'py' : null);
70
+
71
+ const out = [];
72
+ const seen = new Set();
73
+ const lines = code.split('\n');
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const text = lines[i];
76
+ // The line must be assembling program text with an interpolation in it.
77
+ if (!CODE_SHAPED_RE.test(text) || !INTERPOLATION_RE.test(text)) continue;
78
+ const line = i + 1;
79
+ const win = _win(raw, line);
80
+ if (!CODE_WRITE_RE.test(win)) continue; // not actually emitted as a file
81
+ if (!EXTERNAL_FIELD_RE.test(win)) continue; // value isn't caller-supplied
82
+ if (IDENTIFIER_VALIDATION_RE.test(win)) continue; // validated — the fix
83
+ if (seen.has(line)) continue;
84
+ seen.add(line);
85
+ out.push({
86
+ id: `codegen-sink:generated-source:${file}:${line}`,
87
+ file, line,
88
+ vuln: 'Untrusted value interpolated into generated source code',
89
+ severity: 'high',
90
+ cwe: 'CWE-94',
91
+ family: 'code-injection',
92
+ subfamily: 'generated-source',
93
+ parser: 'CODEGEN',
94
+ confidence: 0.5,
95
+ description:
96
+ 'A value taken from a parsed schema/config surface is interpolated into text that this program writes out '
97
+ + 'as source code, with no identifier or allow-list validation in between. The generated file is later '
98
+ + 'imported and executed, so the injection executes in a different process than the one that emitted it — '
99
+ + 'which is why no eval/exec appears anywhere near this line and why ordinary injection rules do not see it.',
100
+ remediation:
101
+ 'Validate every interpolated value against the grammar of what it becomes — `str.isidentifier()` for a '
102
+ + 'name, an explicit allow-list for a type or import path — and reject embedded newlines and carriage '
103
+ + 'returns, which can smuggle statements past a generated comment.',
104
+ checkedFor: 'an identifier/allow-list/escape validation applied to the interpolated value within 14 lines',
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ export const _internals = { CODE_WRITE_RE, CODE_SHAPED_RE, INTERPOLATION_RE, EXTERNAL_FIELD_RE, IDENTIFIER_VALIDATION_RE };