Skip to content
Merged
118 changes: 114 additions & 4 deletions .github/workflows/daily-secrets-analysis.lock.yml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion .github/workflows/daily-secrets-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ tools:
mode: gh-proxy
toolsets: [default, discussions]
bash: true
cache-memory: true
timeout-minutes: 20
imports:
- uses: shared/daily-audit-base.md
Expand Down Expand Up @@ -192,9 +193,11 @@ fi

If available, compare with historical data (this will work after first run):

Read `/tmp/gh-aw/cache-memory/secrets-stats.json` if it exists and compare it with today's statistics before overwriting it. If no baseline exists, report that this is the first run.

```bash
# Save current stats for next run
cat > /tmp/gh-aw/agent/secrets-stats.json << EOF
cat > /tmp/gh-aw/cache-memory/secrets-stats.json << EOF
{
"date": "$(date -I)",
"total_workflows": $TOTAL_WORKFLOWS,
Expand Down
13 changes: 6 additions & 7 deletions actions/setup/js/memory_file_eligibility.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ const { globPatternToRegex } = require("./glob_pattern_helpers.cjs");
/**
* Compile a space-separated FILE_GLOB_FILTER string into an array of RegExp patterns.
* Patterns are matched against the file's path relative to the memory directory root.
* Slashless patterns (e.g. "*.json") match files directly at that root (depth 0), matching
* the documented FILE_GLOB_FILTER contract (e.g. "history.jsonl" at the memory directory
* root matches "*.jsonl"). Patterns containing "/" match the full relative path unchanged.
* Slashless patterns (e.g. "*.json") match files at the memory directory root only.
* Use a recursive glob pattern to match files in subdirectories. Patterns containing
* "/" match the full relative path unchanged.
*
* @param {string} fileGlobFilter - Space-separated glob patterns (may be empty)
* @returns {{ patternStrs: string[], compiledPatterns: RegExp[] }}
Expand Down Expand Up @@ -59,13 +59,13 @@ function isMemoryFileEligible(relativeFilePath, allowedExtensions, compiledPatte
/**
* Recursively scan a memory directory and delete any file that is not eligible
* for persistence per the allowed-extensions and file-glob filters. Deleted files
* are logged (not treated as errors) so that downstream validation, artifact
* are reported as warnings (not treated as errors) so that downstream validation, artifact
* upload, and push steps only ever see the same effective file set.
*
* @param {string} memoryDir - Path to the memory directory to filter in place
* @param {string[]} allowedExtensions - Allowed extensions (e.g. [".json"]); empty means allow all
* @param {string} fileGlobFilter - Space-separated glob patterns; empty means allow all
* @param {{ info: (message: string) => void }} core - Actions core module
* @param {{ info: (message: string) => void, warning: (message: string) => void }} core - Actions core module
* @returns {{ kept: string[], removed: Array<{ path: string, reason: string }> }}
*/
function filterIneligibleMemoryFiles(memoryDir, allowedExtensions, fileGlobFilter, core) {
Expand Down Expand Up @@ -112,8 +112,7 @@ function filterIneligibleMemoryFiles(memoryDir, allowedExtensions, fileGlobFilte
scanDirectory(memoryDir);

if (removed.length > 0) {
core.info(`Ignored ${removed.length} ineligible file(s) before validation/upload:`);
removed.forEach(f => core.info(` - ${f.path} (${f.reason})`));
core.warning(`Ignored ${removed.length} ineligible file(s) before validation/upload:\n${removed.map(f => ` - ${f.path} (${f.reason})`).join("\n")}`);
}

return { kept, removed };
Expand Down
49 changes: 43 additions & 6 deletions actions/setup/js/memory_file_eligibility.test.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
Expand Down Expand Up @@ -41,13 +41,33 @@ describe("memory_file_eligibility.cjs", () => {
expect(isMemoryFileEligible("sub/notes.json", [".json"], compiledPatterns).eligible).toBe(true);
});

it("matches slashless patterns against root-level (depth 0) files, per the documented FILE_GLOB_FILTER contract", () => {
// FILE_GLOB_FILTER docs (push_repo_memory.cjs) document that a file at the memory
// directory root, e.g. "history.jsonl", is matched by the slashless pattern "*.jsonl".
it("matches slashless patterns only at the memory root", () => {
const { compiledPatterns } = compileFileGlobPatterns("*.jsonl");
expect(isMemoryFileEligible("history.jsonl", [], compiledPatterns).eligible).toBe(true);
// A nested file should not match a slashless pattern (single * doesn't cross directories).
expect(isMemoryFileEligible("sub/history.jsonl", [], compiledPatterns).eligible).toBe(false);
expect(isMemoryFileEligible("sub/archive/history.jsonl", [], compiledPatterns).eligible).toBe(false);
expect(isMemoryFileEligible("sub\\archive\\history.jsonl", [], compiledPatterns).eligible).toBe(false);
expect(isMemoryFileEligible("sub/history.json", [], compiledPatterns).eligible).toBe(false);
});

it("matches exact slashless filenames without accepting suffix matches", () => {
const { compiledPatterns } = compileFileGlobPatterns("history.jsonl");
expect(isMemoryFileEligible("history.jsonl", [], compiledPatterns).eligible).toBe(true);
expect(isMemoryFileEligible("sub/archive/history.jsonl", [], compiledPatterns).eligible).toBe(false);
expect(isMemoryFileEligible("sub/old-history.jsonl", [], compiledPatterns).eligible).toBe(false);
});

it("matches nested files only when a recursive glob is configured", () => {
const { compiledPatterns } = compileFileGlobPatterns("**/history.jsonl");
expect(isMemoryFileEligible("sub/archive/history.jsonl", [], compiledPatterns).eligible).toBe(true);
expect(isMemoryFileEligible("sub/old-history.jsonl", [], compiledPatterns).eligible).toBe(false);
});

it("keeps directory-containing patterns relative to the memory root", () => {
const { compiledPatterns } = compileFileGlobPatterns("sub/*.json");
expect(isMemoryFileEligible("sub/data.json", [], compiledPatterns).eligible).toBe(true);
expect(isMemoryFileEligible("other/sub/data.json", [], compiledPatterns).eligible).toBe(false);
expect(isMemoryFileEligible("sub/archive/data.json", [], compiledPatterns).eligible).toBe(false);
});
});

Expand All @@ -65,9 +85,10 @@ describe("memory_file_eligibility.cjs", () => {

describe("filterIneligibleMemoryFiles", () => {
let tmpDir;
const mockCore = { info: () => {} };
const mockCore = { info: vi.fn(), warning: vi.fn() };

afterEach(() => {
vi.clearAllMocks();
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
Expand Down Expand Up @@ -109,6 +130,22 @@ describe("memory_file_eligibility.cjs", () => {

expect(result.kept).toEqual(["sub/keep.json"]);
expect(result.removed.map(f => f.path).sort()).toEqual(["sub/skip-ext.md", "sub/skip-glob.json"]);
expect(mockCore.warning).toHaveBeenCalledOnce();
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("sub/skip-ext.md"));
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("sub/skip-glob.json"));
});

it("keeps nested files matching recursive globs without warning", () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-memory-filter-"));
fs.mkdirSync(path.join(tmpDir, "sub", "archive"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "sub", "archive", "history.jsonl"), '{"id":1}\n');

const result = filterIneligibleMemoryFiles(tmpDir, [".jsonl"], "**/*.jsonl", mockCore);

expect(result.kept).toEqual(["sub/archive/history.jsonl"]);
expect(result.removed).toEqual([]);
expect(fs.existsSync(path.join(tmpDir, "sub", "archive", "history.jsonl"))).toBe(true);
expect(mockCore.warning).not.toHaveBeenCalled();
});

it("handles nested directories, skipping .git", () => {
Expand Down
3 changes: 1 addition & 2 deletions actions/setup/js/push_repo_memory.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -734,8 +734,7 @@ async function main() {
scanDirectory(sourceMemoryPath);
core.info(`Scan complete: ${filesToCopy.length} file(s) accepted, ${filteredOutFiles.length} file(s) filtered out`);
if (filteredOutFiles.length > 0) {
core.info(`Filtered-out files (${filteredOutFiles.length}):`);
filteredOutFiles.forEach(f => core.info(` - ${f.path} (${f.reason})`));
core.warning(`Filtered-out files (${filteredOutFiles.length}):\n${filteredOutFiles.map(f => ` - ${f.path} (${f.reason})`).join("\n")}`);
}
if (filesToCopy.length > 0 && filesToCopy.length <= 10) {
core.info("Accepted files:");
Expand Down
101 changes: 86 additions & 15 deletions actions/setup/js/push_repo_memory.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { execFileSync, execSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import { createRequire } from "node:module";
import vm from "node:vm";
import { globPatternToRegex } from "./glob_pattern_helpers.cjs";
import {
applyTemporaryIdSubstitutions,
Expand All @@ -18,6 +20,74 @@ import {
const mockCore = { info: vi.fn(), setFailed: vi.fn() };
global.core = mockCore;

describe("push_repo_memory.cjs - nested memory persistence", () => {
it("pushes nested files matching recursive globs and warns with skipped paths", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "repo-memory-nested-"));
const artifactDir = path.join(tempDir, "artifact");
const workspaceDir = path.join(tempDir, "workspace");
const core = { info: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn() };
const pushSignedCommits = vi.fn().mockResolvedValue(undefined);
const execGitSync = vi.fn(args => {
if (args[0] === "status") return "?? archive/deep/history.jsonl\n?? state.json\n";
if (args[0] === "rev-parse") return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n";
return "";
});
const scriptPath = path.join(import.meta.dirname, "push_repo_memory.cjs");
const require = createRequire(import.meta.url);
const module = { exports: {} };
const mocks = {
"./git_helpers.cjs": { execGitSync },
"./git_patch_utils.cjs": { getStagedPatchDiffSizeBytes: () => 100 },
"./git_auth_helpers.cjs": { getGitAuthEnv: () => ({}) },
"./push_signed_commits.cjs": { pushSignedCommits },
};

try {
fs.mkdirSync(path.join(artifactDir, "archive", "deep"), { recursive: true });
fs.mkdirSync(workspaceDir);
fs.writeFileSync(path.join(artifactDir, "archive", "deep", "history.jsonl"), '{"id":1}\n');
fs.writeFileSync(path.join(artifactDir, "state.json"), "{}");
fs.writeFileSync(path.join(artifactDir, "archive", "ignored.md"), "ignored");
fs.writeFileSync(path.join(artifactDir, "archive", "ignored.bin"), "ignored");

vm.runInNewContext(fs.readFileSync(scriptPath, "utf8"), {
require: id => mocks[id] || require(id),
module,
core,
context: { repo: { owner: "owner", repo: "repo" } },
github: {},
exec: {},
process: {
env: {
ARTIFACT_DIR: artifactDir,
GITHUB_WORKSPACE: workspaceDir,
MEMORY_ID: "test",
TARGET_REPO: "owner/repo",
BRANCH_NAME: "memory/test",
GH_TOKEN: "test-token",
FILE_GLOB_FILTER: "*.json **/*.jsonl",
ALLOWED_EXTENSIONS: '[".json", ".jsonl", ".md"]',
},
},
});

await module.exports.main();

expect(core.setFailed).not.toHaveBeenCalled();
expect(fs.readFileSync(path.join(workspaceDir, "archive", "deep", "history.jsonl"), "utf8")).toBe('{"id":1}\n');
expect(fs.existsSync(path.join(workspaceDir, "state.json"))).toBe(true);
expect(fs.existsSync(path.join(workspaceDir, "archive", "ignored.md"))).toBe(false);
expect(execGitSync).toHaveBeenCalledWith(["add", "--sparse", "--", ":(literal)archive/deep/history.jsonl", ":(literal)state.json"], { stdio: "inherit", cwd: workspaceDir });
expect(pushSignedCommits).toHaveBeenCalledOnce();
expect(core.warning).toHaveBeenCalledOnce();
expect(core.warning).toHaveBeenCalledWith(expect.stringContaining("archive/ignored.md (no pattern matched)"));
expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('archive/ignored.bin (disallowed extension ".bin")'));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});

describe("push_repo_memory.cjs - untrusted ledger artifacts", () => {
it("ignores coverage files and artifacts that overwrite trusted shards", () => {
const trusted = new Set(["00000000-0000-4000-8000-000000000000"]);
Expand Down Expand Up @@ -1308,31 +1378,32 @@ describe("push_repo_memory.cjs - shell injection security tests", () => {
});

it("should safely handle malicious commit messages", () => {
// Test that malicious commit messages would be treated as literals
const maliciousMessages = ["Update; rm -rf /", "Update && curl evil.com", "Update\nmalicious command", 'Update"; echo hacked'];
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "push-repo-memory-commit-"));
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "repo-memory-commit-message-"));
const maliciousMessages = ["Update; touch pwned", "Update && touch pwned", "Update\n touch pwned", 'Update"; touch pwned'];

// With spawnSync and args array, these would be literal commit messages
// No shell interpretation occurs
try {
execFileSync("git", ["init", "--quiet"], { cwd: repoDir, stdio: "pipe" });
for (const message of maliciousMessages) {
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test"], { cwd: tempDir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });

for (const [index, message] of maliciousMessages.entries()) {
const filePath = `file-${index}`;
fs.writeFileSync(path.join(tempDir, filePath), "test");
execFileSync("git", ["add", filePath], { cwd: tempDir });

const { spawnSync } = require("child_process");
// Note: This would fail in actual use because there are no staged changes
// But it demonstrates that special characters are treated literally
const result = spawnSync("git", ["commit", "-m", message], {
cwd: repoDir,
cwd: tempDir,
encoding: "utf8",
stdio: "pipe",
});

// The command would fail (no staged changes), but importantly:
// The malicious part of the message should NOT be executed
// Special characters like ; && | should be part of the commit message, not shell operators
expect(result.status).not.toBe(0);
expect(result.status).toBe(0);
expect(execFileSync("git", ["log", "-1", "--format=%B"], { cwd: tempDir, encoding: "utf8" }).trimEnd()).toBe(message);
expect(fs.existsSync(path.join(tempDir, "pwned"))).toBe(false);
}
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/validate_memory_step.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const { formatJSONFiles, runCustomMemoryValidation, writeValidationMarker, clear
const { filterIneligibleMemoryFiles } = require("./memory_file_eligibility.cjs");

/**
* @param {{ error: (message: string) => void, info: (message: string) => void, setFailed: (message: string) => void }} core
* @param {{ error: (message: string) => void, info: (message: string) => void, warning: (message: string) => void, setFailed: (message: string) => void }} core
* @param {{
* kind: "repo" | "cache" | "drive",
* formatJSON?: boolean,
Expand Down
4 changes: 4 additions & 0 deletions actions/setup/js/validate_memory_step.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe("validateMemoryStep", () => {
const messages = [];
const core = {
info: message => messages.push(message),
warning: message => messages.push(`warning: ${message}`),
error: message => messages.push(`error: ${message}`),
setFailed: message => messages.push(`failed: ${message}`),
};
Expand All @@ -42,6 +43,7 @@ describe("validateMemoryStep", () => {
fs.writeFileSync(path.join(tempDir, "state.json"), "{}");
const core = {
info: () => {},
warning: () => {},
error: () => {},
setFailed: () => {},
};
Expand All @@ -56,6 +58,7 @@ describe("validateMemoryStep", () => {
const messages = [];
const core = {
info: message => messages.push(message),
warning: message => messages.push(`warning: ${message}`),
error: message => messages.push(`error: ${message}`),
setFailed: message => messages.push(`failed: ${message}`),
};
Expand All @@ -65,6 +68,7 @@ describe("validateMemoryStep", () => {
}

expect(messages.some(m => m.startsWith("failed:"))).toBe(false);
expect(messages).toContain('warning: Ignored 1 ineligible file(s) before validation/upload:\n - notes.json.new (disallowed extension ".new")');
expect(fs.existsSync(path.join(tempDir, "notes.json"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, "notes.json.new"))).toBe(false);
});
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/repo-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ tools:
---
```

Mounts at `/tmp/gh-aw/repo-memory-{id}/` during workflow execution. The required `id` determines the folder name, and `branch-name` defaults to `{branch-prefix}/{id}` with `memory` as the default prefix. Files are stored inside the branch under that branch-name path. File globs always match the relative path within the artifact directory, so never include the branch name; slashless patterns such as `*.json` match only files at the artifact root (depth 0).
Mounts at `/tmp/gh-aw/repo-memory-{id}/` during workflow execution. The required `id` determines the folder name, and `branch-name` defaults to `{branch-prefix}/{id}` with `memory` as the default prefix. Files are stored inside the branch under that branch-name path. File globs match paths relative to the memory directory, so never include the branch name. Slashless patterns such as `*.json` match files at the memory root only. Use `**/*.json` to match nested JSON files; combine it with `*.json` to match both root and nested files. Patterns containing `/`, such as `metrics/*.json`, match the full relative path; `*` does not cross directories, while `**` does. Files excluded by the glob or extension filters produce warnings listing their paths.

## Behavior

Expand Down
10 changes: 10 additions & 0 deletions eslint-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ This project hosts custom ESLint linters for `/actions/setup/js`.
| [`require-page-counter-increment-in-while-true-loop`](#require-page-counter-increment-in-while-true-loop) | Require page counters to advance in manual `while (true)` pagination loops |
| [`require-getexecoutput-exitcode-check`](#require-getexecoutput-exitcode-check) | Require `exitCode` / returned exit code to be read after `getExecOutput()` or `exec()` with `{ ignoreReturnCode: true }` |
| [`prefer-actions-exec-over-child-process`](#prefer-actions-exec-over-child-process) | Prefer `@actions/exec` over `child_process` to spawn processes that run to completion |
| [`no-unguarded-async-timer-callback`](#no-unguarded-async-timer-callback) | Require async timer callbacks to wrap their entire body in try/catch |
| [`require-finite-check-before-date-toisostring`](#require-finite-check-before-date-toisostring) | Validate dynamic dates before calling `toISOString()` or `toJSON()` |

### `no-empty-catch-block`

Expand Down Expand Up @@ -1220,3 +1222,11 @@ const normalized = value.replace("_", "-");
```js
const normalized = value.replaceAll("_", "-");
```

### `no-unguarded-async-timer-callback`

Disallow async callbacks passed to `setTimeout()`, `setInterval()`, or `setImmediate()` unless the entire callback body is wrapped in `try/catch`. These timer functions discard the callback's returned promise, so uncaught errors become unhandled rejections.

### `require-finite-check-before-date-toisostring`

Require validation of dynamic values passed to `new Date(...)` before calling `toISOString()` or `toJSON()`, since invalid dates throw a `RangeError`. Validate the input with `Number.isFinite()` / `Number.isNaN()` or wrap the serialization in `try/catch`.
Loading