Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/publish-blog-draft.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Publish blog draft on merge

on:
pull_request:
types: [closed]
branches: [main]

jobs:
publish:
if: >
github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.title, '[blog-draft]')
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}

- uses: actions/setup-node@v4
with:
node-version: '20'
Comment on lines +18 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "publish-blog-draft.yml" -type f

Repository: Knowcap-V2/knowcap-website

Length of output: 113


🏁 Script executed:

cat -n .github/workflows/publish-blog-draft.yml

Repository: Knowcap-V2/knowcap-website

Length of output: 2533


Pin third-party actions to specific commit SHAs and disable credential persistence.

This workflow has contents: write permission and performs git push operations. Using mutable action tags (@v4) and persisted credentials from checkout increases the compromise blast radius unnecessarily.

  • Replace actions/checkout@v4 with a pinned commit SHA and add persist-credentials: false
  • Replace actions/setup-node@v4 with a pinned commit SHA
  • Pass GITHUB_TOKEN explicitly to the git push command instead of relying on persisted credentials:
    git push "https://x-access-token:${GITHUB_TOKEN}`@github.com/`${GITHUB_REPOSITORY}.git" HEAD:main
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 18-21: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 18-18: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 23-23: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish-blog-draft.yml around lines 18 - 25, The workflow
uses mutable action version tags (v4) which increases security risk and relies
on persisted credentials from the checkout action. Replace the
`actions/checkout@v4` reference with a pinned commit SHA, add
`persist-credentials: false` to disable credential persistence in that step, and
replace `actions/setup-node@v4` with a pinned commit SHA as well. Additionally,
identify any git push commands in the workflow and update them to explicitly
pass the GITHUB_TOKEN environment variable instead of relying on auto-persisted
credentials from checkout.

Source: Linters/SAST tools


- name: Find draft files merged by this PR
id: find_drafts
run: |
git fetch origin ${{ github.event.pull_request.head.sha }} --depth=2
FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} \
| grep '^docs/content-pipeline/drafts/.*\.md$' || true)
echo "drafts<<EOF" >> $GITHUB_OUTPUT
echo "$FILES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "Found drafts: $FILES"

- name: Publish each draft
if: steps.find_drafts.outputs.drafts != ''
run: |
while IFS= read -r draft; do
[ -z "$draft" ] && continue
if [ -f "$draft" ]; then
echo "Publishing: $draft"
node scripts/publish-draft.mjs "$draft"
else
echo "Draft already removed from main (possibly already published): $draft"
fi
done <<< "${{ steps.find_drafts.outputs.drafts }}"

Comment on lines +38 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Avoid direct template interpolation in shell here-string (command injection risk).

Using ${{ ... }} directly in done <<< "..." allows shell interpretation of attacker-controlled filename content.

🛡️ Suggested fix
       - name: Publish each draft
         if: steps.find_drafts.outputs.drafts != ''
+        env:
+          DRAFTS: ${{ steps.find_drafts.outputs.drafts }}
         run: |
           while IFS= read -r draft; do
             [ -z "$draft" ] && continue
             if [ -f "$draft" ]; then
               echo "Publishing: $draft"
               node scripts/publish-draft.mjs "$draft"
             else
               echo "Draft already removed from main (possibly already published): $draft"
             fi
-          done <<< "${{ steps.find_drafts.outputs.drafts }}"
+          done <<< "$DRAFTS"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Publish each draft
if: steps.find_drafts.outputs.drafts != ''
run: |
while IFS= read -r draft; do
[ -z "$draft" ] && continue
if [ -f "$draft" ]; then
echo "Publishing: $draft"
node scripts/publish-draft.mjs "$draft"
else
echo "Draft already removed from main (possibly already published): $draft"
fi
done <<< "${{ steps.find_drafts.outputs.drafts }}"
- name: Publish each draft
if: steps.find_drafts.outputs.drafts != ''
env:
DRAFTS: ${{ steps.find_drafts.outputs.drafts }}
run: |
while IFS= read -r draft; do
[ -z "$draft" ] && continue
if [ -f "$draft" ]; then
echo "Publishing: $draft"
node scripts/publish-draft.mjs "$draft"
else
echo "Draft already removed from main (possibly already published): $draft"
fi
done <<< "$DRAFTS"
🧰 Tools
🪛 zizmor (1.25.2)

[info] 49-49: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish-blog-draft.yml around lines 38 - 50, The
here-string at the end of the while loop directly interpolates the GitHub
Actions template variable without proper quoting, which creates a command
injection vulnerability. To fix this, add double quotes around the ${{
steps.find_drafts.outputs.drafts }} variable in the here-string syntax (done <<<
"${{ ... }}") to ensure the output is treated as a literal string and any
special characters in filenames are not interpreted by the shell.

Source: Linters/SAST tools

- name: Commit published posts
run: |
git config user.name "knowcap-bot"
git config user.email "bot@knowcap.ai"
git add app/content/blog/
git add docs/content-pipeline/drafts/
if git diff --cached --quiet; then
echo "Nothing to commit (no new blog posts)"
else
git commit -m "publish(blog): auto-publish from PR #${{ github.event.pull_request.number }} [skip ci]"
git push
fi
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
title: "Global Technology Audit Guide: The Evidence Gap MENA Audit Firms Face"
slug: global-technology-audit-guide-evidence-gap-mena
mode: thesis
persona: mena-audit-firms
target_keyword: "global technology audit guide"
target_keyword_5y_mena_interest: null
geo_score: 74
est_word_count: 1599
draft_date: 2026-06-18
description: "ISACA's Global Technology Audit Guide assumes interview evidence is documented. For MENA audit firms under PDPL enforcement, Knowcap's human-confirmation layer makes every finding traceable."
tags: ["mena-audit-firms", "internal-audit", "isaca", "pdpl", "evidence-documentation", "audit-trail", "technology-audit"]
author: "Hassan Arslan"
lang: "en"
dir: "ltr"
source_knowcap_ids: []
embedded_screenshots: []
status: draft
---

## Global Technology Audit Guide: The Evidence Gap in Every ISACA-Based Audit

The ISACA Global Technology Audit Guide runs to hundreds of pages on IT general controls, data analytics, and third-party risk. It says almost nothing about what to do when the evidence trail starts in a meeting that was never documented.

## The Pain Every MENA IT Auditor Knows

MENA audit firms conducting technology audits — ISO 27001 gap assessments, PDPL readiness reviews, internal control walkthroughs of client ERP systems — collect most of their evidence through structured interviews. An IT auditor sits with a client's CISO, network administrator, or data protection officer, and their responses become the backbone of the internal audit report.

The Global Technology Audit Guide assumes this evidence is organized. In practice, it lives in the auditor's notebook, a partially completed checklist, or the memory of whoever ran the session.

When a finding gets challenged — by the client, by a senior partner, or by SDAIA under PDPL Article 36 — the team needs to show where the finding originated. "We discussed it in the second walkthrough" does not constitute a defensible evidence chain. In a market where 48 SDAIA enforcement decisions landed in the first twelve months of active PDPL enforcement, with fines reaching SAR 5 million per violation, that gap is no longer abstract.

## Why Generic AI Meeting Notes Tools Don't Fix This

Generic AI meeting notes tools summarize audit interviews quickly. Otter, Read.ai, and Fireflies produce readable recaps within minutes. The problem is not summary accuracy. The problem is what happens when an AI-summarized claim becomes an audit finding.

An AI summary is the model's interpretation of what was said — not an attestation that the auditor reviewed that interpretation and confirmed it. When a PDPL Article 35/36 challenge arrives, the chain that matters is not "the tool produced a summary" but "a named auditor reviewed this claim against the source and confirmed it."

Generic AI meeting notes tools produce output. They do not produce human-confirmed evidence. The Global Technology Audit Guide does not specify a documentation standard for AI-assisted evidence collection — this gap remains each firm's problem to solve, or to discover during an inquiry. Tools built for meeting productivity treat all participants as collaborators; in a technology audit interview, the client's system administrator is a respondent under review, not a co-author. The confirmation step — an auditor explicitly attesting "this is what the respondent said, I reviewed it against the recording" — is what distinguishes audit evidence from a summary.

## What the Verified-Facts Model Changes for Audit Documentation

Knowcap's verified-fact model adds the layer the technology audit workflow is missing: a requirement that a named human confirm each extracted claim before it can be cited as evidence.

The workflow is non-bypassable. An audit interview session is recorded and processed through Knowcap's extraction pipeline. Knowcap extracts claims categorized as decisions, risks, facts, and tasks — the same taxonomy an IT audit team applies to interview findings. These sit in an inbox as pending items, not usable yet. A named auditor reviews each one against the timestamped source recording and confirms or rejects it individually. Confirmed claims become evidence, each anchored to the exact second in the recording where the respondent made the statement.

The no-Confirm-All constraint matters. Knowcap does not permit bulk confirmation — every item requires individual review. For an audit team, this mirrors the discipline of examining each working paper before signing off. Bulk confirmation produces the equivalent of a signed blank checklist: technically documented, legally undefensible.

This is what the Global Technology Audit Guide leaves to each firm's judgment. Knowcap makes it a structural constraint. Every confirmed claim carries the reviewer's identity, the confirmation timestamp, and a permalink to the exact source segment. The internal audit report's findings trace to confirmed items, not to a summary.

Firms that process client PII in engagement walkthroughs and use AI to assist in documentation face direct exposure under PDPL Articles 35 and 36 if that chain cannot trace each claim to a named reviewer. SDAIA's enforcement record suggests the regulator asks for exactly that chain.

## What This Looks Like in Practice for a MENA Audit Engagement

An IT auditor at a MENA firm is conducting a PDPL readiness review for a client in Saudi Arabia. The engagement covers three walkthroughs: systems architecture, data flows and retention, and HR processes for employee data. Each session runs 60–90 minutes.

With Knowcap, the workflow changes at the evidence collection stage. Each walkthrough is recorded and processed. By the time the session ends, the relevant claims are waiting in the team's inbox — the system administrator's statement about data retention periods, the decision to exclude a legacy CRM from PDPL scope, the risk flagged about third-party processor contracts — each extracted, categorized, and waiting for individual confirmation.

The audit manager reviews each item, confirms it against the source recording, and adds confirmed items to the evidence base. The internal audit report findings trace to confirmed items with timestamps and source references. When the client's legal counsel asks "where does Finding 3 come from?", the answer is a timestamped clip from the second walkthrough, confirmed by a named auditor on a specific date.

Nothing about the Global Technology Audit Guide methodology changes. The evidence layer becomes traceable rather than assumed — that is what converts an AI tool from a convenience into a defensible part of the workflow.

## FAQ

### Does Knowcap align with the ISACA Global Technology Audit Guide methodology?

Knowcap does not replace the Global Technology Audit Guide framework — it adds a documentation layer beneath the evidence collection process the GTAG assumes exists. Every technology audit conducted under GTAG guidance involves structured interviews, walkthroughs, and review sessions with client personnel. Knowcap processes recordings of those sessions, extracts claims into five audit-relevant categories — decisions, risks, facts, tasks, and notes — and routes each to a named auditor for individual confirmation before it can be cited in a working paper. The GTAG specifies what evidence to collect and how to evaluate it. Knowcap ensures the chain from collection to confirmed evidence is timestamped and attributable to a named reviewer. The two sit at different layers: GTAG governs audit procedure; Knowcap governs evidence provenance.

### How does Knowcap handle Arabic-language audit interviews in KSA and UAE?

Knowcap processes Arabic-language recordings and produces transcripts through the extraction pipeline. Arabic audit interviews present specific challenges: dialect variation, code-switching between Arabic and English, and technical terminology borrowed from English but spoken in Arabic. The pipeline is language-agnostic — it processes transcripts regardless of language and routes extracted claims to the confirmation inbox. Because every claim requires individual review before becoming evidence, the confirmation step acts as a quality gate: an auditor reviewing a claim against the source recording catches any extraction errors before they enter the working paper. For KSA engagements requiring Arabic output, confirmed claims are reviewed by Arabic-language auditors on the team. Human confirmation is the accuracy mechanism, not the transcription model.

### What does "human-confirmed evidence" mean for an audit working paper?

In a conventional working paper, a finding's evidence trail takes one of two forms: a direct quote with a document reference, or an auditor notation recording what the respondent stated. Both rely on contemporaneous documentation. Human-confirmed evidence in Knowcap adds a specific layer: the claim was extracted from a source recording, reviewed by a named auditor against the timestamped source segment, and confirmed as an accurate representation of what was said. Each confirmed claim carries the reviewer's identity, the confirmation timestamp, and a permalink to the exact source segment. This chain is more complete than a notebook entry: the raw source is preserved, the review step is logged, and the confirmation is attributable to a specific person at a specific time. Under PDPL Article 36, where SDAIA can request documentation of AI-assisted decisions, this is what a generic summary cannot provide.

### Can Knowcap's confirmation log hold up in a PDPL Article 36 inquiry?

PDPL Article 36 covers automated processing that produces legally significant effects. Audit firms using AI to assist in drafting internal audit reports — where those reports trigger compliance actions, regulatory disclosures, or contractual consequences — should treat AI-assistance documentation as a regulatory exposure. Knowcap's confirmation log records every confirmed claim: which AI extraction was reviewed, who confirmed it, when, and against which source segment. SDAIA's 48 enforcement decisions in the first twelve months of PDPL enforcement suggest the regulator looks for documentation of how AI-assisted outputs were reviewed. The confirmation log is designed to produce that chain. Knowcap does not provide legal advice; audit firms should consult counsel on their specific PDPL exposure.

### Can Knowcap run alongside an existing audit management platform?

Knowcap is not an audit management platform. Engagement letters, risk matrices, working-paper templates, and final report generation stay in whatever platform the audit firm already uses — Caseware, TeamMate, or an internal system. Knowcap handles one part of the evidence pipeline: turning interview recordings into human-confirmed, source-attributed claims that can be cited in existing working papers. The integration is manual at the evidence-entry stage: an auditor copies confirmed claims with source references into the working paper. For most MENA audit teams, the entry point is simpler: use Knowcap for interview evidence, document confirmed findings in existing templates, keep the two workflows parallel.

## Closing

The ISACA Global Technology Audit Guide has not changed how evidence travels from an interview room into a defensible internal audit report. For MENA audit firms with AI in the room, Knowcap addresses that specific gap.
183 changes: 183 additions & 0 deletions scripts/publish-draft.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* publish-draft.mjs
* Transforms a blogger-routine draft into a shippable blog post.
*
* Usage: node publish-draft.mjs <draft-path>
* e.g. node scripts/publish-draft.mjs docs/content-pipeline/drafts/my-post.md
*
* Reads the draft, transforms frontmatter, writes to app/content/blog/<slug>.md,
* then deletes the draft file.
*/

import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = path.resolve(__dirname, '..')
const BLOG_DIR = path.join(REPO_ROOT, 'app', 'content', 'blog')

// Persona → geo audiences mapping
const PERSONA_GEO = {
'odoo-partners': ['UAE', 'KSA', 'Egypt'],
'mena-audit-firms': ['UAE', 'KSA', 'Egypt'],
'mena-agencies': ['UAE', 'KSA', 'Egypt'],
'regulated-verticals': ['UAE', 'KSA', 'Egypt'],
}

// Internal-only frontmatter keys — drop before publishing
const DROP_KEYS = [
'mode', 'target_keyword', 'target_keyword_5y_mena_interest',
'geo_score', 'est_word_count', 'embedded_screenshots', 'status',
]

function parseFrontmatter(raw) {
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/m)
if (!match) throw new Error('No frontmatter found')
const yamlBlock = match[1]
const body = match[2]

// Minimal YAML parser for the shape we produce (no nested objects)
const meta = {}
for (const line of yamlBlock.split('\n')) {
const m = line.match(/^(\w[\w_-]*):\s*(.*)$/)
if (!m) continue
const [, k, v] = m
if (v.startsWith('[') && v.endsWith(']')) {
// inline array
const items = v.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
meta[k] = items
} else if (v.startsWith('"') || v.startsWith("'")) {
meta[k] = v.replace(/^["']|["']$/g, '')
} else if (v === 'null' || v === '') {
meta[k] = null
} else if (!isNaN(Number(v))) {
meta[k] = Number(v)
} else {
meta[k] = v
}
}
return { meta, body }
}

function extractDescription(body) {
// First non-heading, non-empty paragraph (after stripping H2 headings)
const lines = body.split('\n')
let paragraph = ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
paragraph = trimmed
break
}
// Truncate to ~160 chars for meta description
if (paragraph.length > 165) {
const cut = paragraph.lastIndexOf(' ', 160)
return paragraph.slice(0, cut > 80 ? cut : 160) + '…'
}
return paragraph
}

function slugToTags(slug, persona, keyword) {
const STOP = new Set(['the','and','for','with','how','are','from','your','that','this','our','their','about','into','when','what','does'])
const words = [...new Set([
...slug.split('-'),
...(keyword || '').split(' '),
persona || '',
])].filter(w => w.length > 3 && !STOP.has(w.toLowerCase()))
return [...new Set(words.map(w => w.toLowerCase()))].slice(0, 8)
}

function readMinutes(wordCount) {
return Math.max(1, Math.ceil((wordCount || 1500) / 200))
}

function buildPublishedFrontmatter(meta, body) {
const pub = {}

// Required fields
pub.title = meta.title || 'Untitled'
pub.slug = meta.slug
pub.date = meta.draft_date || new Date().toISOString().slice(0, 10)
pub.author = meta.author || 'Hassan Arslan'

// Description — use meta.description if set, else auto-extract
pub.description = meta.description || extractDescription(body)

// Tags
pub.tags = meta.tags?.length
? meta.tags
: slugToTags(meta.slug || '', meta.persona, meta.target_keyword)

// Geo + persona
pub.geo_audiences = PERSONA_GEO[meta.persona] || ['UAE', 'KSA', 'Egypt']
pub.target_persona = meta.persona || null

// Derived
pub.related_pages = meta.related_pages || []
pub.source_knowcap_ids = meta.source_knowcap_ids || []
pub.read_minutes = readMinutes(meta.est_word_count)
pub.lang = meta.lang || 'en'
pub.dir = meta.dir || 'ltr'

return pub
}

function serializeFrontmatter(obj) {
const lines = ['---']
for (const [k, v] of Object.entries(obj)) {
if (v === null || v === undefined) continue
if (Array.isArray(v)) {
if (v.length === 0) {
lines.push(`${k}: []`)
} else {
lines.push(`${k}:`)
for (const item of v) lines.push(` - "${item}"`)
}
} else if (typeof v === 'string' && (v.includes(':') || v.includes('"') || v.includes("'"))) {
lines.push(`${k}: "${v.replace(/"/g, '\\"')}"`)
} else {
lines.push(`${k}: ${v}`)
}
}
lines.push('---')
return lines.join('\n')
}

// ── Main ──────────────────────────────────────────────────────────────────────

const draftPath = process.argv[2]
if (!draftPath) {
console.error('Usage: node scripts/publish-draft.mjs <draft-path>')
process.exit(1)
}

const absPath = path.isAbsolute(draftPath) ? draftPath : path.join(REPO_ROOT, draftPath)
if (!fs.existsSync(absPath)) {
console.error(`Draft not found: ${absPath}`)
process.exit(1)
}

const raw = fs.readFileSync(absPath, 'utf8')
const { meta, body } = parseFrontmatter(raw)

if (!meta.slug) {
console.error('Draft missing slug in frontmatter')
process.exit(1)
}

const pubMeta = buildPublishedFrontmatter(meta, body)
const published = serializeFrontmatter(pubMeta) + '\n' + body

const outPath = path.join(BLOG_DIR, `${meta.slug}.md`)
if (fs.existsSync(outPath)) {
Comment on lines +165 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Enforce a strict slug format before building the publish path.

meta.slug is currently trusted as a path component. A slug containing ../ can escape app/content/blog and overwrite arbitrary repo files during auto-publish.

🔒 Suggested fix
+const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
+
 if (!meta.slug) {
   console.error('Draft missing slug in frontmatter')
   process.exit(1)
 }
+if (!SLUG_RE.test(meta.slug)) {
+  console.error(`Invalid slug: ${meta.slug}`)
+  process.exit(1)
+}
 
 const pubMeta = buildPublishedFrontmatter(meta, body)
 const published = serializeFrontmatter(pubMeta) + '\n' + body
 
-const outPath = path.join(BLOG_DIR, `${meta.slug}.md`)
+const outPath = path.resolve(BLOG_DIR, `${meta.slug}.md`)
+if (!outPath.startsWith(path.resolve(BLOG_DIR) + path.sep)) {
+  console.error(`Refusing to write outside blog directory: ${outPath}`)
+  process.exit(1)
+}
 if (fs.existsSync(outPath)) {
   console.error(`Blog post already exists: ${outPath}`)
   process.exit(1)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!meta.slug) {
console.error('Draft missing slug in frontmatter')
process.exit(1)
}
const pubMeta = buildPublishedFrontmatter(meta, body)
const published = serializeFrontmatter(pubMeta) + '\n' + body
const outPath = path.join(BLOG_DIR, `${meta.slug}.md`)
if (fs.existsSync(outPath)) {
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
if (!meta.slug) {
console.error('Draft missing slug in frontmatter')
process.exit(1)
}
if (!SLUG_RE.test(meta.slug)) {
console.error(`Invalid slug: ${meta.slug}`)
process.exit(1)
}
const pubMeta = buildPublishedFrontmatter(meta, body)
const published = serializeFrontmatter(pubMeta) + '\n' + body
const outPath = path.resolve(BLOG_DIR, `${meta.slug}.md`)
if (!outPath.startsWith(path.resolve(BLOG_DIR) + path.sep)) {
console.error(`Refusing to write outside blog directory: ${outPath}`)
process.exit(1)
}
if (fs.existsSync(outPath)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/publish-draft.mjs` around lines 165 - 174, The meta.slug is being
used directly as a path component in the outPath construction without
validation, creating a path traversal vulnerability where a slug containing
sequences like ../ could escape the BLOG_DIR directory. Add validation after the
existing slug existence check to ensure meta.slug contains only safe characters
and does not include path traversal patterns. The validation should reject slugs
containing forward slashes, backslashes, or dot sequences before passing it to
path.join(BLOG_DIR, `${meta.slug}.md`) to construct the outPath safely.

console.error(`Blog post already exists: ${outPath}`)
process.exit(1)
}

fs.writeFileSync(outPath, published, 'utf8')
fs.unlinkSync(absPath)

console.log(`Published: ${outPath}`)
console.log(`Deleted draft: ${absPath}`)
Loading