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
50 changes: 49 additions & 1 deletion .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,52 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 22
- run: pnpm audit --audit-level low
- name: Install dependencies for complete audit paths
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Audit dependencies (two approved exceptions expire October 22)
env:
AUDIT_EXCEPTIONS_EXPIRE_AT: '2026-10-22T05:37:00Z'
run: |
# Omer approved these two temporary risks on 2026-10-08, not a release.
# braces: CLI glob size/depth guards mitigate input; tooling remains affected.
# http-cache-semantics: reviewed Lerna callers use private caches on fresh runners.
# Remove exceptions when patched; expiry never rolls forward automatically.
report="$RUNNER_TEMP/dependency-audit.json"
audit_status=0
pnpm audit --json > "$report" || audit_status=$?
cat "$report"
# Registry errors, muted findings, unexpected statuses/schema must fail closed.
jq -e --argjson status "$audit_status" '
type == "object" and (has("error") | not) and .muted == [] and
(.advisories | type == "object") and
(.metadata.vulnerabilities | keys == ["critical", "high", "info", "low", "moderate"]) and
(.metadata.vulnerabilities | all(.[]; type == "number" and . >= 0)) and
(([.metadata.vulnerabilities[]] | add) == (.advisories | length)) and
(($status == 0 and (.advisories | length) == 0) or
($status == 1 and (.advisories | length) > 0)) and
(.advisories | all(.[];
(.module_name | type == "string") and
(.findings | type == "array" and length > 0) and
(.findings | all(.[]; (.version | type == "string") and
(.paths | type == "array" and length > 0) and
(.paths | all(.[]; type == "string"))))))
' "$report" > /dev/null
jq --arg expires "$AUDIT_EXCEPTIONS_EXPIRE_AT" --argjson now "$(date +%s)" '
[.advisories[] | select(
($now < ($expires | fromdateiso8601) and
((.github_advisory_id == "GHSA-vfj7-8cjw-p6xm" and
.module_name == "braces" and all(.findings[]; .version == "3.0.3")) or
(.github_advisory_id == "GHSA-ch52-4w7c-c8xp" and
.module_name == "http-cache-semantics" and
all(.findings[]; .version == "4.2.0" and
all(.paths[]; startswith(". > lerna@9.0.7 > ")))))) | not)]
' "$report" > "$RUNNER_TEMP/blocking-advisories.json"
if ! jq -e 'length == 0' "$RUNNER_TEMP/blocking-advisories.json" > /dev/null; then
cat "$RUNNER_TEMP/blocking-advisories.json"
echo "::error::Audit contains unapproved findings or the temporary exceptions have expired."
exit 1
fi
if jq -e '.advisories | length > 0' "$report" > /dev/null; then
echo "::warning::Audit passed with approved risk exceptions until $AUDIT_EXCEPTIONS_EXPIRE_AT; dependencies remain affected."
echo "Audit: temporary risk accepted for the reported braces/http-cache-semantics findings until $AUDIT_EXCEPTIONS_EXPIRE_AT. Not release approval." >> "$GITHUB_STEP_SUMMARY"
fi
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,14 @@
"vite@>=7.0.0 <7.3.5": "^7.3.5",
"esbuild@>=0.27.3 <0.28.1": "0.28.1",
"tar@<7.5.21": "7.5.21",
"brace-expansion@>=5.0.0 <5.0.9": "5.0.9",
"brace-expansion@>=5.0.0 <5.0.12": "5.0.12",
"js-yaml@>=4.0.0 <4.3.2": "4.3.2",
"axios@>=1.0.0 <1.18.0": "1.18.0",
"axios@>=1.0.0 <1.20.0": "1.20.0",
"pacote@>=21.0.0 <21.5.1": "21.5.1",
"smol-toml@<1.7.1": "1.7.1"
"smol-toml@<1.9.0": "1.9.0",
"nx@>=22.0.0 <22.7.10": "22.7.10",
"source-map-js@>=1.0.0 <1.2.2": "1.2.2",
"postcss-selector-parser@<7.1.6": "7.1.6"
}
}
}
57 changes: 57 additions & 0 deletions packages/cli/src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { validateConfig, formatValidationErrors } from './validator.js';
*/
const CONFIG_FILENAMES = ['contractual.yaml', 'contractual.yml'];

// Bound the input and recursive AST depth before fast-glob invokes braces.
const MAX_GLOB_PATTERN_LENGTH = 1024;
const MAX_GLOB_NESTING = 32;

/**
* Error thrown when config cannot be loaded
*/
Expand All @@ -20,6 +24,58 @@ export class ConfigError extends Error {
}
}

function validateGlobPattern(pattern: string, contractName: string): void {
if (pattern.length > MAX_GLOB_PATTERN_LENGTH) {
throw new ConfigError(
`Glob for contract "${contractName}" exceeds ${MAX_GLOB_PATTERN_LENGTH} characters after resolving its path`
);
}

const nesting: string[] = [];
let quote: string | undefined;
let brackets = 0;

for (let index = 0; index < pattern.length; index++) {
const character = pattern[index];
if (character === '\\') {
index++;
continue;
}
if (quote) {
if (character === quote) quote = undefined;
continue;
}
if (brackets > 0) {
if (character === '[') brackets++;
if (character === ']') brackets--;
continue;
}
if (character === '[') {
brackets++;
continue;
}
if (character === '"' || character === "'" || character === '`') {
quote = character;
continue;
}
// Parentheses also create recursive nodes in the braces parser. Only a
// matching closer ends a node; quoted/class/escaped closers are literals.
if (character === '{' || character === '(') {
nesting.push(character);
if (nesting.length > MAX_GLOB_NESTING) {
throw new ConfigError(
`Glob for contract "${contractName}" exceeds ${MAX_GLOB_NESTING} levels of brace/parenthesis nesting`
);
}
} else if (
(character === '}' && nesting.at(-1) === '{') ||
(character === ')' && nesting.at(-1) === '(')
) {
nesting.pop();
}
}
}

/**
* Find contractual.yaml by walking up from the given directory
*/
Expand Down Expand Up @@ -79,6 +135,7 @@ export function resolveContractPaths(

// Check if it's a glob pattern
if (pattern.includes('*')) {
validateGlobPattern(absolutePattern, contract.name);
const matches = fg.sync(absolutePattern, { onlyFiles: true });
if (matches.length === 0) {
console.warn(
Expand Down
Loading
Loading