Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.
- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
.specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
- .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
-> packages/casan-harness/... (.specify/logs state kept)
- .claude/launch.json, .gitea/*-runbook.md: path prefixes
- CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
- policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.
Full gate from the new root: PASS=64 FAIL=0 SKIP=3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
9.2 KiB
PowerShell
219 lines
9.2 KiB
PowerShell
#!/usr/bin/env pwsh
|
|
# CASAN H4 Security Harness - PowerShell port of security-check.sh
|
|
# Usage:
|
|
# security-check.ps1 <input-file> <output-file> [input|output]
|
|
#
|
|
# input mode: blocks prompt injection / jailbreak / secrets, masks PII.
|
|
# output mode: redacts PII/secrets from generated output, flags risky language.
|
|
# Exit codes: 0=pass, 2=blocked, 64=usage error
|
|
|
|
param(
|
|
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
|
|
[Parameter(Mandatory=$true, Position=1)][string]$OutputFile,
|
|
[Parameter(Position=2)][ValidateSet("input","output")][string]$Mode = "input"
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
|
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
|
$logDir = Join-Path $projectRoot ".specify/logs"
|
|
$traceDir = Join-Path $logDir "trace"
|
|
$auditDir = Join-Path $logDir "audit"
|
|
$securityDir = Join-Path $projectRoot ".specify/security"
|
|
|
|
foreach ($d in @($traceDir, $auditDir, (Split-Path $OutputFile -Parent))) {
|
|
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
|
|
}
|
|
|
|
if (!(Test-Path $InputFile)) {
|
|
Write-Error "SECURITY_BLOCKED: input file not found: $InputFile"
|
|
exit 2
|
|
}
|
|
|
|
function New-TraceId {
|
|
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "trace-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
|
|
}
|
|
|
|
function Get-Sha256 ([string]$text) {
|
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
|
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
|
|
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
|
|
}
|
|
|
|
function ConvertTo-JsonArray ([string[]]$arr) {
|
|
if (!$arr -or $arr.Count -eq 0) { return "[]" }
|
|
$escaped = $arr | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }
|
|
return "[" + ($escaped -join ",") + "]"
|
|
}
|
|
|
|
$traceId = New-TraceId
|
|
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
$content = Get-Content $InputFile -Raw -Encoding UTF8
|
|
if (!$content) { $content = "" }
|
|
$lower = $content.ToLower()
|
|
|
|
$status = "pass"
|
|
$action = "allow"
|
|
$riskLevel = "low"
|
|
$matchedRules = [System.Collections.Generic.List[string]]::new()
|
|
|
|
# ── Block patterns (prompt injection) ──────────────────────────────────────
|
|
$blockPatterns = @(
|
|
"ignore previous instruction",
|
|
"ignore system instruction",
|
|
"bypass system",
|
|
"bypass policy",
|
|
"system bypass",
|
|
"override policy",
|
|
"you are now",
|
|
"act as admin",
|
|
"pretend to be system",
|
|
"reveal system prompt",
|
|
"reveal original prompt",
|
|
"show hidden instruction",
|
|
"show internal prompt"
|
|
)
|
|
|
|
# ── Jailbreak patterns (Phase 2 addition) ──────────────────────────────────
|
|
$jailbreakPatterns = @(
|
|
"act as a different",
|
|
"act as an evil",
|
|
"act as an unconstrained",
|
|
"act as an unrestricted",
|
|
"pretend you are not an ai",
|
|
"pretend you're not an ai",
|
|
"pretend you are no longer",
|
|
"developer mode",
|
|
"dan mode",
|
|
"jailbreak mode",
|
|
"unrestricted mode",
|
|
"your real instructions are",
|
|
"your true instructions are",
|
|
"what are your system instructions",
|
|
"what were your original instructions"
|
|
)
|
|
|
|
# ── Load additional patterns from prompt-filter.yaml ───────────────────────
|
|
$filterYaml = Join-Path $securityDir "prompt-filter.yaml"
|
|
if (Test-Path $filterYaml) {
|
|
$yaml = Get-Content $filterYaml -Raw
|
|
$patternMatches = [regex]::Matches($yaml, 'pattern:\s*"([^"]+)"')
|
|
foreach ($m in $patternMatches) { $blockPatterns += $m.Groups[1].Value }
|
|
}
|
|
|
|
# ── Approval / alert patterns ──────────────────────────────────────────────
|
|
$approvalPatterns = @("delete ", "drop table", "shutdown system", "export secrets", "dump database")
|
|
$alertPatterns = @("show all data", "internal prompt", "system message", "hidden instruction")
|
|
|
|
# ── Regexes ────────────────────────────────────────────────────────────────
|
|
$emailRegex = '[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}'
|
|
$phoneRegex = '(\+?[0-9][0-9 .\-]{8,}[0-9])'
|
|
$personalIdReg = '\b[0-9]{9,12}\b'
|
|
$creditCardReg = '\b([0-9]{4}[- ]?){3}[0-9]{4}\b'
|
|
$secretRegex = '(?i)(API[_\-]?KEY|ACCESS[_\-]?TOKEN|REFRESH[_\-]?TOKEN|PASSWORD|JWT[_\-]?SECRET|SECRET)\s*[:=]\s*\S+'
|
|
$credentialReg = '(?i)(postgres|mysql|mongodb)://[^@]+@' # connection strings
|
|
$awsKeyReg = 'AKIA[0-9A-Z]{16}'
|
|
|
|
if ($Mode -eq "input") {
|
|
# Block patterns check
|
|
foreach ($p in ($blockPatterns + $jailbreakPatterns)) {
|
|
if ($p -and $lower -match [regex]::Escape($p)) {
|
|
$status = "blocked"; $action = "block"; $riskLevel = "high"
|
|
$matchedRules.Add("prompt-injection:$p")
|
|
}
|
|
}
|
|
|
|
# Credit card PII
|
|
if ($content -match $creditCardReg) {
|
|
$status = "blocked"; $action = "block"; $riskLevel = "high"
|
|
$matchedRules.Add("pii-credit-card")
|
|
}
|
|
|
|
# Hardcoded secrets / credentials (Phase 2 addition)
|
|
if (($content -match $secretRegex) -or ($content -match $credentialReg) -or ($content -match $awsKeyReg)) {
|
|
$status = "blocked"; $action = "block"; $riskLevel = "high"
|
|
$matchedRules.Add("secret-in-input")
|
|
}
|
|
|
|
# Approval patterns (only if not already blocked)
|
|
if ($status -ne "blocked") {
|
|
foreach ($p in $approvalPatterns) {
|
|
if ($lower -match [regex]::Escape($p)) {
|
|
$status = "requires_approval"; $action = "require_approval"; $riskLevel = "high"
|
|
$matchedRules.Add("unsafe-action:$p")
|
|
}
|
|
}
|
|
}
|
|
|
|
# Alert patterns
|
|
if ($status -ne "blocked") {
|
|
foreach ($p in $alertPatterns) {
|
|
if ($lower -match [regex]::Escape($p)) {
|
|
if ($riskLevel -eq "low") { $riskLevel = "medium" }
|
|
$action = "alert"
|
|
$matchedRules.Add("suspicious:$p")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── PII masking + secret redaction (both modes) ───────────────────────────
|
|
$safeContent = $content
|
|
$safeContent = [regex]::Replace($safeContent, $emailRegex, '***MASKED_EMAIL***')
|
|
$safeContent = [regex]::Replace($safeContent, $phoneRegex, '***MASKED_PHONE***')
|
|
$safeContent = [regex]::Replace($safeContent, $personalIdReg, '***MASKED_ID***')
|
|
$safeContent = [regex]::Replace($safeContent, $secretRegex, '[REDACTED_SECRET]')
|
|
$safeContent = [regex]::Replace($safeContent, $credentialReg, '[REDACTED_CONNSTRING]://')
|
|
$safeContent = [regex]::Replace($safeContent, $awsKeyReg, '[REDACTED_AWSKEY]')
|
|
|
|
if ($Mode -eq "output") {
|
|
if ($content -match $secretRegex) {
|
|
$action = "redact"
|
|
if ($riskLevel -eq "low") { $riskLevel = "medium" }
|
|
$matchedRules.Add("secret-redacted-output")
|
|
}
|
|
if ($lower -match "(maybe|might be incorrect|i am not sure|uncertain)") {
|
|
$action = "flag"
|
|
if ($riskLevel -eq "low") { $riskLevel = "medium" }
|
|
$matchedRules.Add("hallucination-risk-language")
|
|
}
|
|
}
|
|
|
|
# ── Hash ───────────────────────────────────────────────────────────────────
|
|
$inputHash = Get-Sha256 $content
|
|
$outputHash = Get-Sha256 $safeContent
|
|
$rulesJson = ConvertTo-JsonArray ($matchedRules.ToArray())
|
|
|
|
# ── Trace JSON ─────────────────────────────────────────────────────────────
|
|
$traceFile = Join-Path $traceDir "security-$traceId.json"
|
|
@"
|
|
{
|
|
"trace_id": "$traceId",
|
|
"timestamp": "$timestamp",
|
|
"harness": "H4-security",
|
|
"mode": "$Mode",
|
|
"status": "$status",
|
|
"action": "$action",
|
|
"risk_level": "$riskLevel",
|
|
"matched_rules": $rulesJson,
|
|
"input_hash": "$inputHash",
|
|
"output_hash": "$outputHash"
|
|
}
|
|
"@ | Set-Content -Path $traceFile -Encoding UTF8
|
|
|
|
# ── Audit JSONL ────────────────────────────────────────────────────────────
|
|
$auditLine = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"H4-security`",`"mode`":`"$Mode`",`"status`":`"$status`",`"action`":`"$action`",`"risk_level`":`"$riskLevel`",`"input_hash`":`"$inputHash`",`"output_hash`":`"$outputHash`"}"
|
|
Add-Content -Path (Join-Path $auditDir "security.jsonl") -Value $auditLine -Encoding UTF8
|
|
|
|
# ── Result ─────────────────────────────────────────────────────────────────
|
|
if ($status -eq "blocked") {
|
|
Set-Content -Path $OutputFile -Value "" -Encoding UTF8
|
|
Write-Error "SECURITY_BLOCKED trace_id=$traceId risk=$riskLevel rules=$rulesJson"
|
|
exit 2
|
|
}
|
|
|
|
Set-Content -Path $OutputFile -Value $safeContent -Encoding UTF8
|
|
Write-Output "SECURITY_$($status.ToUpper()) trace_id=$traceId risk=$riskLevel action=$action output=$OutputFile"
|
|
exit 0
|