Skip to content
Draft
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
24 changes: 24 additions & 0 deletions CHANGELOG.d/report-secret-ingestion-redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
### Security

- Normalize imported findings through one fail-closed serialization boundary:
suppress complete snippets for secret-category and genuinely sensitive-rule
findings, recursively redact raw/credential-named extension fields, and
selectively redact obvious authorization, credential-assignment,
provider-token, and JWT material from all other text before reports, SARIF,
or control-plane persistence.
- Sanitize secret-bearing mapping keys, post-coercion text, plural/camel-case
sensitive fields, arbitrary Authorization schemes, and AWS access-key
identifiers while bounding snippet inspection before pattern matching.
- Consume multiline quoted credentials, cover scoped `sk-proj-*` and
`sk-svcacct-*` tokens, validate report line numbers without deferred string
coercion, and inspect bounded lookahead so truncation cannot expose a token
prefix at the report boundary.
- Cover suffixed compound credential fields and triple-quoted/backtick values;
lookahead now marks source-aligned secret spans without moving text from
beyond the configured snippet boundary into serialized output.
- Treat escaped characters inside triple-quoted credentials as content rather
than a closing delimiter, preventing the remaining secret body from leaking.
- Preserve non-sensitive evidence and existing truncation behavior. Unknown
secret formats outside raw/credential-named fields depend on a
format-specific redaction pattern; regression fixtures use only synthetic
values. Provider-related non-secret rules do not suppress benign evidence.
110 changes: 100 additions & 10 deletions appguardrail_core/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,43 @@

from typing import Any, Iterable

from appguardrail_core.redaction import (
redact_sensitive_prefix,
redact_sensitive_text,
redact_sensitive_value,
)

SEVERITIES = ("CRITICAL", "HIGH", "WARNING", "INFO")
DEPLOY_BLOCKING_SEVERITIES = {"CRITICAL", "HIGH"}
NON_BLOCKING_CONTEXTS = {"doc", "test", "example", "scanner-fixture"}

_SEVERITY_ORDER = {severity: index for index, severity in enumerate(SEVERITIES)}
_SEV_SET = frozenset(SEVERITIES)
_SENSITIVE_RULE_SEGMENTS = frozenset({"hardcoded"})
_SENSITIVE_ASSET_SEGMENTS = frozenset(
{
"credential",
"credentials",
"jwt",
"password",
"secret",
"secrets",
"token",
}
)
_SENSITIVE_DISCLOSURE_SEGMENTS = frozenset(
{"checked", "committed", "disclosure", "exposure", "leak", "leaked"}
)
_SENSITIVE_RULE_IDS = frozenset(
{
"env-file-with-secrets-committed",
"next-public-secret",
"nextjs-env-secret-client-prefix",
"stripe-secret-key-client-exposure",
}
)
_REDACTED_SENSITIVE_SNIPPET = "[REDACTED: sensitive match suppressed]"
_REDACTION_LOOKAHEAD = 128


def normalize_finding(
Expand All @@ -18,7 +49,9 @@ def normalize_finding(
snippet_max_len: int = 400,
) -> dict[str, Any]:
"""Return a normalized, report-safe AppGuardrail finding dictionary."""
normalized = dict(finding)
untrusted_finding = dict(finding)
untrusted_snippet = untrusted_finding.pop("snippet", "")
normalized = redact_sensitive_value(untrusted_finding)

sev = normalized.get("severity")
if type(sev) is not str or sev not in _SEV_SET:
Expand Down Expand Up @@ -48,7 +81,17 @@ def normalize_finding(
except Exception:
normalized["file"] = "n/a"

if not normalized.get("line"):
line = normalized.get("line")
if type(line) is int and line > 0:
normalized["line"] = line
elif (
type(line) is str
and len(line) <= 20
and line.isascii()
and line.isdecimal()
):
normalized["line"] = max(1, int(line))
else:
normalized["line"] = 1

cat = normalized.get("category")
Expand Down Expand Up @@ -89,13 +132,20 @@ def normalize_finding(
except Exception:
normalized["verification"] = "Rerun AppGuardrail after remediation."

snip = normalized.get("snippet")
snip = untrusted_snippet
if type(snip) is not str or not snip:
try:
snip = str(snip or "")
except Exception:
snip = ""
normalized["snippet"] = safe_report_snippet(snip, max_len=snippet_max_len)
safe_snippet = safe_report_snippet(
snip,
max_len=snippet_max_len,
rule_id=normalized["rule_id"],
category=normalized["category"],
)
normalized = redact_sensitive_value(normalized)
normalized["snippet"] = safe_snippet

return normalized

Expand Down Expand Up @@ -194,19 +244,59 @@ def finding_sort_key(finding: dict[str, Any]) -> tuple[int, str, str]:
)


def safe_report_snippet(snippet: str, max_len: int = 400) -> str:
"""Trim report evidence without carrying oversized raw snippets."""
def safe_report_snippet(
snippet: str,
max_len: int = 400,
*,
rule_id: str = "",
category: str = "",
) -> str:
"""Redact sensitive report evidence and bound the remaining snippet."""
if type(snippet) is not str:
try:
snippet = str(snippet or "")
except Exception:
snippet = ""
if not snippet:
return ""
snippet = snippet.replace("\r\n", "\n").replace("\r", "\n").strip()
if len(snippet) <= max_len:
return snippet
return snippet[:max_len].rstrip() + "\n...[truncated]"
normalized_rule_id = rule_id.strip().lower() if type(rule_id) is str else ""
normalized_category = category.strip().lower() if type(category) is str else ""
rule_segments = frozenset(
filter(None, normalized_rule_id.replace("_", "-").split("-"))
)
sensitive_asset = bool(rule_segments.intersection(_SENSITIVE_ASSET_SEGMENTS))
sensitive_asset = sensitive_asset or {"private", "key"}.issubset(rule_segments)
sensitive_asset = sensitive_asset or {"api", "key"}.issubset(rule_segments)
disclosure_semantics = bool(
rule_segments.intersection(_SENSITIVE_DISCLOSURE_SEGMENTS)
)
if (
normalized_category == "secrets"
or normalized_rule_id in _SENSITIVE_RULE_IDS
or bool(rule_segments.intersection(_SENSITIVE_RULE_SEGMENTS))
or (sensitive_asset and disclosure_semantics)
):
return _REDACTED_SENSITIVE_SNIPPET
max_len = max(0, max_len)
was_truncated = len(snippet) > max_len
inspection_len = max_len + _REDACTION_LOOKAHEAD
visible_snippet = snippet[:max_len]
inspection_snippet = (
snippet[:inspection_len] if len(snippet) > inspection_len else snippet
)
visible_snippet = visible_snippet.replace("\r\n", "\n").replace("\r", "\n")
inspection_snippet = inspection_snippet.replace("\r\n", "\n").replace(
"\r", "\n"
)
if was_truncated:
snippet = redact_sensitive_prefix(
inspection_snippet,
visible_length=len(visible_snippet),
)
else:
snippet = redact_sensitive_text(visible_snippet)
snippet = snippet.strip()
return snippet.rstrip() + ("\n...[truncated]" if was_truncated else "")


def _as_tuple(value: Any) -> tuple[str, ...]:
Expand Down
22 changes: 7 additions & 15 deletions appguardrail_core/issueops.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import re
from typing import Any

from appguardrail_core.redaction import SENSITIVE_TEXT_PATTERNS

FAILURES = {"failure", "cancelled", "timed_out", "action_required"}
SECURITY_TERMS = (
"strix",
Expand All @@ -21,6 +23,9 @@
DEFAULT_MAX_LOG_LINES = 200
MAX_GITHUB_RUN_ID_DIGITS = 20

# Compatibility alias for callers that inspected the former module-level list.
SECRET_RE = list(SENSITIVE_TEXT_PATTERNS)

ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
TS_RE = re.compile(
r"^\ufeff?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z[^\S\r\n]*",
Expand All @@ -36,19 +41,6 @@
for separator in ("\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029")
}
)
SECRET_RE = [
re.compile(r"(?i)(authorization:\s*(?:bearer|token)\s+)[^\s]+"),
re.compile(
r"(?im)\b((?:api[_-]?key|token|secret|password|private[_-]?key)\s*[:=]\s*)"
r"(?:'(?:\\.|[^'\\\r\n])*(?:'|(?=\r?$))|"
r'"(?:\\.|[^"\\\r\n])*(?:"|(?=\r?$))|'
r"[^'\"\s]+['\"]?)"
),
re.compile(
r"\b(?:gh[opsu]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9]{20,})\b"
),
re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
]
PRIMARY_LOG_RE = [
re.compile(pattern, re.IGNORECASE)
for pattern in (
Expand Down Expand Up @@ -112,8 +104,8 @@ def redact(log: str) -> str:
if text.endswith("\n"):
text = text[:-1]
text = TS_RE.sub("", text)
for regex in SECRET_RE:
text = regex.sub(
for pattern in SECRET_RE:
text = pattern.sub(
lambda match: (
f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
),
Expand Down
Loading
Loading