mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.112.0 fix(cso): --paths scopes an audit and reports every skipped path (no more hand-trimmed copies) (#3230)
* fix(cso): --paths scopes an audit and reports every skipped path A repo over the 64 MiB cap was audited on hand-trimmed copies, so the verdict read assessed over partial input. --paths records out-of-scope files unread as snapshot-inputs gaps (coverage partial), keeps --diff fail-closed, and carries the scope through submit, recheck and replay. Field list 2 item 12; related #2993. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(cso): close /review findings on --paths scoping - Comparison mode hashes out-of-scope files in bounded 1 MiB chunks and re-hashes them in both stability passes instead of reading whole files. - --diff --paths refuses every changed path outside the scope except host dependency trees, including credential and agent configuration. - --paths prefixes that report persistence would rewrite are refused. - In-scope gaps sort first; out-of-scope files and deletions share the skipped tier and are dropped from transformations. - History is limited to --paths, reported as partial, and history queries on out-of-scope paths are refused. - A --diff recheck refused for out-of-scope changes says to start a new audit. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(cso): troubleshooting names --paths for the source cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): /cso --paths entry Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.112.0 fix(cso): --paths scopes an audit and reports every skipped path (#3230) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f592d67826
commit
299e051010
11 files changed
+588
-59
No files matched your search
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.112.0] - 2026-10-17
|
||||
|
||||
**`/cso --paths` audits part of a repository and says exactly what it skipped.** A repository over /cso's 64 MiB source cap used to get one piece of advice, "run /cso on a smaller checkout". The trimmed copy then read as the whole repository, and the verdict never said what was left out. `gstack-cso start --paths src/core,src/mcp` now audits only those prefixes and reports every other path as skipped, with snapshot and history coverage marked partial.
|
||||
|
||||
### What this means for you
|
||||
|
||||
- **`--paths` narrows an audit without hiding what it left out.** Files outside the prefixes are never copied, read or counted toward the cap. The run's snapshot manifest lists each one, the report counts them, and snapshot-inputs and history-inputs coverage read partial, so the verdict can't look complete. Git history is limited to the same prefixes.
|
||||
- **`--diff --paths` fails closed.** A changed, added or deleted file outside the prefixes refuses the run and is named, including `.npmrc`, `bunfig.toml`, `.claude/` and other credential or agent configuration. Only dependency trees such as `node_modules`, which /cso never reads, are exempt.
|
||||
- **Findings stay in scope.** A finding, recheck evidence or `history` query on a skipped path is refused, and rechecks and replays keep the original `--paths`.
|
||||
- **The source-cap message points to `--paths`** and warns against hand-trimmed copies. The manifest cap and the 100,000-entry limit still count every file, so their advice is unchanged (#2993).
|
||||
|
||||
### If you…
|
||||
|
||||
- **…audited a hand-trimmed copy to get under the cap:** rerun /cso on the full checkout with `--paths` set to the directories you want audited, so the report says what was skipped.
|
||||
|
||||
### For contributors
|
||||
|
||||
- New `test/cso-audit-paths.test.ts` covers the cap advice, the withheld entries and gaps, the `--diff` refusal in both directions, bounded 1 MiB hashing of out-of-scope files when a comparison base is set, recheck and replay scope, and out-of-scope history and findings.
|
||||
- Known limits: under `--paths` the original-source hash ignores content changes outside the scope, out-of-scope hashing has no byte cap beyond the time budget, `--paths` with `--comprehensive` runs on a trimmed tree, and a recheck refuses when the audited directory was renamed.
|
||||
|
||||
## [1.91.111.0] - 2026-10-16
|
||||
|
||||
**Four background reds in the paid PR gate were caused by a detector, a fixture interface, a gstack helper and a Review Army workflow block, not by the PRs that hit them.** Each is fixed where it starts, and each fix has a free regression built from the red run's captured events. Over the last 99 gate runs, 10 of 28 full-selection PR runs had a red. Replayed under these fixes, that drops to 8, and red verdicts drop from 13 to 10. No threshold, budget, trial count or quarantine changed.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.111.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.112.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+7
-6
@@ -45,6 +45,7 @@ Do not send findings, source, secrets, harnesses, or bundles to gbrain, telemetr
|
||||
| `--base <ref>` | Select the comparison base, including for diff mode. |
|
||||
| `--budget <seconds>` | Bound wall-clock investigation time, including setup. |
|
||||
| `--offline` | Disable helper, scanner, download, and advisory network access. |
|
||||
| `--paths <p,q>` | Audit only these repo prefixes (never a trimmed copy); others become snapshot-inputs gaps. |
|
||||
|
||||
## Mode Resolution
|
||||
|
||||
@@ -64,10 +65,10 @@ Phases 0, 1, 12, 13, and 14 always run for an investigation. Select the remainin
|
||||
|
||||
Diff mode may read unchanged callers, middleware, schemas, configuration, and dependencies needed for assessment. Report out-of-scope variants as follow-up scope. Historical-secret coverage uses only the helper's pinned base/snapshot.
|
||||
|
||||
Use `gstack-cso schema` for JSON inputs and `--help` for the installed contract. Below, `gstack-cso` means the trusted absolute launcher:
|
||||
`gstack-cso schema` gives JSON inputs; `--help` the installed contract. Below, `gstack-cso` is the trusted absolute launcher:
|
||||
|
||||
```text
|
||||
gstack-cso start --repo <repo> [scope/diff/base/budget/offline flags] [--comprehensive]
|
||||
gstack-cso start --repo <repo> [scope/diff/base/budget/offline/paths] [--comprehensive]
|
||||
gstack-cso doctor --repo <repo>
|
||||
gstack-cso resume <run>
|
||||
gstack-cso replay <bundle> [--source <matching-source>]
|
||||
@@ -117,7 +118,7 @@ Use the snapshot inventory and redacted source to map stacks, **actors, assets,
|
||||
|
||||
Use stack detection to prioritize nested services and cross-language paths. Comprehensive setup supports Node, Bun, Python, and Rails only with a matching qualified runtime profile. Missing profiles, runtimes, or tools are execution prerequisites, not vulnerabilities. Reduce coverage only for unfinished assessment work.
|
||||
|
||||
In comprehensive mode, review snapshot transformations before reproduction. If sanitization removes or replaces the tested boundary, block reproduction. Never claim sanitized configuration equivalence without evidence. Readiness failures do not block static work.
|
||||
In comprehensive mode, review snapshot transformations first; block reproduction if sanitization removed or replaced the tested boundary. Never claim sanitized configuration equivalence without evidence. Readiness failures do not block static work.
|
||||
|
||||
### Phase 1: Attack surface census
|
||||
|
||||
@@ -136,7 +137,7 @@ This **CSO evidence rubric** governs CSO instead of shared review confidence ins
|
||||
|
||||
Daily reports contain **supported findings**: a concrete attacker-controlled entrypoint, a path across an intended security boundary, demonstrated impact, and a challenge of relevant protective controls. Comprehensive reports retain unresolved candidates separately as **labeled hypotheses**, never mixed into supported totals. Disproved candidates are retained as disposition/coverage evidence, not vulnerabilities.
|
||||
|
||||
Do not apply blanket exclusions for development dependencies, availability/resource attacks, historical secrets, user-role prompt injection, or gstack-owned skills. Analyze attacker control and impact. UUIDs do not provide authorization; user-controlled URL paths can still cross a sensitive boundary; environment variables may originate from untrusted workflows; and safe defaults can be bypassed by framework escape hatches. Missing hardening alone needs a concrete failure scenario before becoming a finding.
|
||||
Do not apply blanket exclusions for development dependencies, availability/resource attacks, historical secrets, user-role prompt injection, or gstack-owned skills. Analyze attacker control and impact. UUIDs are not authorization; user-controlled URL paths can cross a sensitive boundary; environment variables may come from untrusted workflows; framework escape hatches can bypass safe defaults. Missing hardening alone needs a concrete failure scenario before becoming a finding.
|
||||
|
||||
For each candidate, use an already-authorized independent reviewer when available. Give it the relevant locations, invariant, and rubric without the producer's conclusion; have it inspect callers, middleware, configuration, validation, legitimate behavior, and mitigations. Use at most three; await them. Do not request broader tool access solely to obtain an independent reviewer. Otherwise perform a separate skeptical pass labeled **sequential challenge; independent agent unavailable**. Record dissent and assumptions. Agreement and scanner warnings do not prove runtime behavior.
|
||||
|
||||
@@ -153,13 +154,13 @@ Keep finding evidence, reproduction outcome, patch validation, test-completion a
|
||||
|
||||
### Phase 13: Report and repair artifacts
|
||||
|
||||
Finish every audit through the helper, including empty, cancelled, blocked, or interrupted audits when possible. Every report begins with **complete**, **partial**, or **not assessed**, followed by scope and material gaps. Completeness is independent of finding count. For an empty supported set, say **“No supported findings in the assessed scope.”**, or **not assessed** if nothing was. Lead incomplete reports with the helper's Status/Ran/Reason/Next lines. Never infer a clean result from setup failure or absent scanner output.
|
||||
Finish every audit through the helper, including empty, cancelled, blocked, or interrupted audits when possible. Every report begins with **complete**, **partial**, or **not assessed**, followed by scope and material gaps; a `--paths` run states partial snapshot-inputs coverage and summarizes skipped paths. Completeness is independent of finding count. For an empty supported set, say **“No supported findings in the assessed scope.”**, or **not assessed** if nothing was. Lead incomplete reports with the helper's Status/Ran/Reason/Next lines. Never infer a clean result from setup failure or absent scanner output.
|
||||
|
||||
Present a compact SECURITY FINDINGS table with stable finding ID, severity, confidence/rationale, evidence state, location, and impact. Each finding needs an attacker scenario, supporting references, counterevidence considered, and a concrete repair recommendation. Include coverage, transformations, scanner versions/outcomes/freshness, runtime prerequisites, timing, and proposed repair-candidate paths. Include `runtime_tested` bundle paths only when an authenticated assertion witness actually produced one. Beside every bundle, show assertion, test-completion, and review assurance exactly as recorded; never collapse those labels or imply that self-reported project tests are authenticated. Comprehensive hypotheses belong in a separate labeled appendix.
|
||||
|
||||
Use `RunReportV3`, `FindingV3`, `CoverageRecord`, `VerificationManifest`, and `RepairBundle` from `lib/cso/contracts.ts` through the installed schema command. Helper validation/persistence is mandatory. If saving fails, report **PERSISTENCE_FAILED** and a safe summary; never claim the report was saved. If redaction fails, withhold the payload entirely; do not show raw excerpts to explain the failure.
|
||||
|
||||
The private state namespace is `security/cso/<repo>/<run>` under the existing state root, outside synchronization allowlists. Execution copies and services are removed immediately. Sanitized snapshots expire after seven days; redacted reports, repair candidates, and bundles after thirty days. Public archive caches are hash-verified, capped at 10 GiB, and evicted by LRU. Explicitly exported artifacts remain under user control. Repair candidates preserve their redacted request, patch, external observations, self-reported project-test outcome, provenance, and required input hashes. Authenticated bundles additionally preserve the inputs required for replay and their separate assurance labels. Currently issued `runtime_tested` bundles record authenticated external assertions and `self_reported` project-test completion.
|
||||
The private state namespace is `security/cso/<repo>/<run>` under the existing state root, outside synchronization allowlists. Execution copies and services are removed immediately. Sanitized snapshots expire after seven days; redacted reports, repair candidates, and bundles after thirty days. Public archive caches are hash-verified, capped at 10 GiB, and evicted by LRU. Explicitly exported artifacts remain under user control. Repair candidates preserve their redacted request, patch, external observations, self-reported project-test outcome, provenance, and required input hashes. Authenticated bundles additionally preserve the inputs required for replay and their separate assurance labels.
|
||||
|
||||
### Phase 14: Recovery and current-source rechecks
|
||||
|
||||
|
||||
+7
-6
@@ -45,6 +45,7 @@ Do not send findings, source, secrets, harnesses, or bundles to gbrain, telemetr
|
||||
| `--base <ref>` | Select the comparison base, including for diff mode. |
|
||||
| `--budget <seconds>` | Bound wall-clock investigation time, including setup. |
|
||||
| `--offline` | Disable helper, scanner, download, and advisory network access. |
|
||||
| `--paths <p,q>` | Audit only these repo prefixes (never a trimmed copy); others become snapshot-inputs gaps. |
|
||||
|
||||
## Mode Resolution
|
||||
|
||||
@@ -64,10 +65,10 @@ Phases 0, 1, 12, 13, and 14 always run for an investigation. Select the remainin
|
||||
|
||||
Diff mode may read unchanged callers, middleware, schemas, configuration, and dependencies needed for assessment. Report out-of-scope variants as follow-up scope. Historical-secret coverage uses only the helper's pinned base/snapshot.
|
||||
|
||||
Use `gstack-cso schema` for JSON inputs and `--help` for the installed contract. Below, `gstack-cso` means the trusted absolute launcher:
|
||||
`gstack-cso schema` gives JSON inputs; `--help` the installed contract. Below, `gstack-cso` is the trusted absolute launcher:
|
||||
|
||||
```text
|
||||
gstack-cso start --repo <repo> [scope/diff/base/budget/offline flags] [--comprehensive]
|
||||
gstack-cso start --repo <repo> [scope/diff/base/budget/offline/paths] [--comprehensive]
|
||||
gstack-cso doctor --repo <repo>
|
||||
gstack-cso resume <run>
|
||||
gstack-cso replay <bundle> [--source <matching-source>]
|
||||
@@ -110,7 +111,7 @@ Use the snapshot inventory and redacted source to map stacks, **actors, assets,
|
||||
|
||||
Use stack detection to prioritize nested services and cross-language paths. Comprehensive setup supports Node, Bun, Python, and Rails only with a matching qualified runtime profile. Missing profiles, runtimes, or tools are execution prerequisites, not vulnerabilities. Reduce coverage only for unfinished assessment work.
|
||||
|
||||
In comprehensive mode, review snapshot transformations before reproduction. If sanitization removes or replaces the tested boundary, block reproduction. Never claim sanitized configuration equivalence without evidence. Readiness failures do not block static work.
|
||||
In comprehensive mode, review snapshot transformations first; block reproduction if sanitization removed or replaced the tested boundary. Never claim sanitized configuration equivalence without evidence. Readiness failures do not block static work.
|
||||
|
||||
### Phase 1: Attack surface census
|
||||
|
||||
@@ -128,7 +129,7 @@ This **CSO evidence rubric** governs CSO instead of shared review confidence ins
|
||||
|
||||
Daily reports contain **supported findings**: a concrete attacker-controlled entrypoint, a path across an intended security boundary, demonstrated impact, and a challenge of relevant protective controls. Comprehensive reports retain unresolved candidates separately as **labeled hypotheses**, never mixed into supported totals. Disproved candidates are retained as disposition/coverage evidence, not vulnerabilities.
|
||||
|
||||
Do not apply blanket exclusions for development dependencies, availability/resource attacks, historical secrets, user-role prompt injection, or gstack-owned skills. Analyze attacker control and impact. UUIDs do not provide authorization; user-controlled URL paths can still cross a sensitive boundary; environment variables may originate from untrusted workflows; and safe defaults can be bypassed by framework escape hatches. Missing hardening alone needs a concrete failure scenario before becoming a finding.
|
||||
Do not apply blanket exclusions for development dependencies, availability/resource attacks, historical secrets, user-role prompt injection, or gstack-owned skills. Analyze attacker control and impact. UUIDs are not authorization; user-controlled URL paths can cross a sensitive boundary; environment variables may come from untrusted workflows; framework escape hatches can bypass safe defaults. Missing hardening alone needs a concrete failure scenario before becoming a finding.
|
||||
|
||||
For each candidate, use an already-authorized independent reviewer when available. Give it the relevant locations, invariant, and rubric without the producer's conclusion; have it inspect callers, middleware, configuration, validation, legitimate behavior, and mitigations. Use at most three; await them. Do not request broader tool access solely to obtain an independent reviewer. Otherwise perform a separate skeptical pass labeled **sequential challenge; independent agent unavailable**. Record dissent and assumptions. Agreement and scanner warnings do not prove runtime behavior.
|
||||
|
||||
@@ -145,13 +146,13 @@ Keep finding evidence, reproduction outcome, patch validation, test-completion a
|
||||
|
||||
### Phase 13: Report and repair artifacts
|
||||
|
||||
Finish every audit through the helper, including empty, cancelled, blocked, or interrupted audits when possible. Every report begins with **complete**, **partial**, or **not assessed**, followed by scope and material gaps. Completeness is independent of finding count. For an empty supported set, say **“No supported findings in the assessed scope.”**, or **not assessed** if nothing was. Lead incomplete reports with the helper's Status/Ran/Reason/Next lines. Never infer a clean result from setup failure or absent scanner output.
|
||||
Finish every audit through the helper, including empty, cancelled, blocked, or interrupted audits when possible. Every report begins with **complete**, **partial**, or **not assessed**, followed by scope and material gaps; a `--paths` run states partial snapshot-inputs coverage and summarizes skipped paths. Completeness is independent of finding count. For an empty supported set, say **“No supported findings in the assessed scope.”**, or **not assessed** if nothing was. Lead incomplete reports with the helper's Status/Ran/Reason/Next lines. Never infer a clean result from setup failure or absent scanner output.
|
||||
|
||||
Present a compact SECURITY FINDINGS table with stable finding ID, severity, confidence/rationale, evidence state, location, and impact. Each finding needs an attacker scenario, supporting references, counterevidence considered, and a concrete repair recommendation. Include coverage, transformations, scanner versions/outcomes/freshness, runtime prerequisites, timing, and proposed repair-candidate paths. Include `runtime_tested` bundle paths only when an authenticated assertion witness actually produced one. Beside every bundle, show assertion, test-completion, and review assurance exactly as recorded; never collapse those labels or imply that self-reported project tests are authenticated. Comprehensive hypotheses belong in a separate labeled appendix.
|
||||
|
||||
Use `RunReportV3`, `FindingV3`, `CoverageRecord`, `VerificationManifest`, and `RepairBundle` from `lib/cso/contracts.ts` through the installed schema command. Helper validation/persistence is mandatory. If saving fails, report **PERSISTENCE_FAILED** and a safe summary; never claim the report was saved. If redaction fails, withhold the payload entirely; do not show raw excerpts to explain the failure.
|
||||
|
||||
The private state namespace is `security/cso/<repo>/<run>` under the existing state root, outside synchronization allowlists. Execution copies and services are removed immediately. Sanitized snapshots expire after seven days; redacted reports, repair candidates, and bundles after thirty days. Public archive caches are hash-verified, capped at 10 GiB, and evicted by LRU. Explicitly exported artifacts remain under user control. Repair candidates preserve their redacted request, patch, external observations, self-reported project-test outcome, provenance, and required input hashes. Authenticated bundles additionally preserve the inputs required for replay and their separate assurance labels. Currently issued `runtime_tested` bundles record authenticated external assertions and `self_reported` project-test completion.
|
||||
The private state namespace is `security/cso/<repo>/<run>` under the existing state root, outside synchronization allowlists. Execution copies and services are removed immediately. Sanitized snapshots expire after seven days; redacted reports, repair candidates, and bundles after thirty days. Public archive caches are hash-verified, capped at 10 GiB, and evicted by LRU. Explicitly exported artifacts remain under user control. Repair candidates preserve their redacted request, patch, external observations, self-reported project-test outcome, provenance, and required input hashes. Authenticated bundles additionally preserve the inputs required for replay and their separate assurance labels.
|
||||
|
||||
### Phase 14: Recovery and current-source rechecks
|
||||
|
||||
|
||||
+12
-2
@@ -1204,8 +1204,18 @@ or too large. Each message names the cap that applied:
|
||||
withheld from the audit.
|
||||
- **Symlinks.** A tracked symlink anywhere in the repository stops the run.
|
||||
|
||||
**Fix.** No setting raises these caps. Run /cso on a smaller checkout that
|
||||
holds the code you want audited and no symlinks. Add your file count or size to
|
||||
**Fix.** No setting raises these caps. For the 64 MiB source cap, rerun with
|
||||
`--paths` set to the comma-separated repo-relative directories you want audited
|
||||
(for example `--paths src/core,src/mcp`). Paths outside them are never
|
||||
captured or assessed and do not count toward the cap; snapshot and history
|
||||
coverage then read **partial**, the report counts every skipped path, and the run's
|
||||
snapshot manifest lists each one, so the verdict says what was not audited.
|
||||
With `--diff`, every changed path outside dependency directories must be inside
|
||||
`--paths`, or the run is refused. Do not audit a hand-trimmed copy: /cso would treat it as the whole
|
||||
repository and report nothing as skipped. `--paths` does not lower the
|
||||
manifest cap or the 100,000-entry limit, because every skipped path still has a
|
||||
manifest entry (about 50,000 files fit). Symlinks must be removed. Add your
|
||||
file count or size to
|
||||
[#2993](https://github.com/garrytan/gstack/issues/2993).
|
||||
|
||||
<a id="cso-scanner-invalid-output"></a>
|
||||
|
||||
+125
-24
@@ -35,7 +35,15 @@ import {
|
||||
validateFinding,
|
||||
validateVerificationRequest,
|
||||
} from './contracts';
|
||||
import { capture, containedFile, assertSnapshot, readSnapshotManifest } from './snapshot';
|
||||
import {
|
||||
capture,
|
||||
containedFile,
|
||||
assertSnapshot,
|
||||
inAuditPaths,
|
||||
noReadPath,
|
||||
OUTSIDE_AUDIT_PATHS,
|
||||
readSnapshotManifest,
|
||||
} from './snapshot';
|
||||
import {
|
||||
assertStateOutside,
|
||||
boundedList,
|
||||
@@ -167,7 +175,7 @@ const GENERATION_LOCK_FD = (() => {
|
||||
void GENERATION_LOCK_FD;
|
||||
const HELP = `gstack-cso ${VERSION} (helper ABI ${ABI})
|
||||
Usage:
|
||||
gstack-cso start --repo PATH [--comprehensive] [--diff] [--base REF] [--budget SECONDS] [--offline] [--infra|--code|--skills|--supply-chain|--owasp|--scope DOMAIN]
|
||||
gstack-cso start --repo PATH [--comprehensive] [--diff] [--base REF] [--budget SECONDS] [--offline] [--paths PREFIX[,PREFIX...]] [--infra|--code|--skills|--supply-chain|--owasp|--scope DOMAIN]
|
||||
gstack-cso doctor --repo PATH
|
||||
gstack-cso provision-images [--setup-summary] [--per-image-seconds 5..300]
|
||||
gstack-cso resume RUN
|
||||
@@ -190,6 +198,7 @@ Usage:
|
||||
gstack-cso inspect-v2 IMPORT_ID
|
||||
gstack-cso schema
|
||||
|
||||
--paths audits only source under the listed repo-relative prefixes; every other path is never captured and is reported as a snapshot-inputs gap.
|
||||
Static runs never execute application code. Target and scanner execution is Docker-only and requires a qualified immutable catalog.`;
|
||||
|
||||
const SCHEMA = {
|
||||
@@ -475,16 +484,28 @@ function planned(scope: string): CoverageRecord[] {
|
||||
// material gaps keep the largest share; findings and events keep the rest.
|
||||
const SNAPSHOT_DISCLOSURE_BUDGET = { gaps: 128 * 1024, transformations: 96 * 1024, exclusions: 32 * 1024 };
|
||||
const UNLISTED = "to keep report.json within its 1 MiB bound; the run's snapshot manifest keeps every entry";
|
||||
function snapshotCoverage(manifest: Awaited<ReturnType<typeof capture>>, scope: string): CoverageRecord {
|
||||
function snapshotCoverage(
|
||||
manifest: Awaited<ReturnType<typeof capture>>,
|
||||
scope: string,
|
||||
auditPaths?: string[],
|
||||
): CoverageRecord {
|
||||
const omitted = manifest.entries.filter((entry) => !entry.executionHash),
|
||||
excluded = omitted.filter((entry) => entry.transformation?.startsWith('excluded:')),
|
||||
excludedSet = new Set(excluded);
|
||||
// Classify every unexplained omission as a material coverage gap. Coverage
|
||||
// must not depend on transformation prose retaining a particular prefix.
|
||||
const unread = omitted.filter((entry) => !excludedSet.has(entry));
|
||||
const deleted = manifest.deletedPaths ?? [],
|
||||
captured = manifest.entries.length - omitted.length,
|
||||
missing = unread.length + deleted.length;
|
||||
// Out-of-scope --paths entries are listed last so they never crowd material
|
||||
// in-scope gaps out of the bounded list.
|
||||
const unread = omitted.filter((entry) => !excludedSet.has(entry)),
|
||||
inScope = unread.filter((entry) => entry.transformation !== OUTSIDE_AUDIT_PATHS),
|
||||
allDeleted = manifest.deletedPaths ?? [],
|
||||
deleted = allDeleted.filter((item) => !auditPaths || inAuditPaths(item.path, auditPaths)),
|
||||
outside = [
|
||||
...unread.filter((entry) => entry.transformation === OUTSIDE_AUDIT_PATHS),
|
||||
...allDeleted.filter((item) => !deleted.includes(item)),
|
||||
];
|
||||
const captured = manifest.entries.length - omitted.length,
|
||||
missing = unread.length + allDeleted.length;
|
||||
return {
|
||||
domain: 'snapshot-inputs',
|
||||
scope,
|
||||
@@ -493,12 +514,17 @@ function snapshotCoverage(manifest: Awaited<ReturnType<typeof capture>>, scope:
|
||||
gaps: boundedList(
|
||||
missing,
|
||||
(index) =>
|
||||
index < unread.length
|
||||
? `${publicSnapshotPath(manifest, unread[index].path, unread[index].pathId).path}: in-scope source payload was unread and withheld from static and runtime assessment`
|
||||
: `${publicSnapshotPath(manifest, deleted[index - unread.length].path, deleted[index - unread.length].pathId).path}: tracked source is deleted from the worktree; only retained history is available for assessment`,
|
||||
index < inScope.length
|
||||
? `${publicSnapshotPath(manifest, inScope[index].path, inScope[index].pathId).path}: in-scope source payload was unread and withheld from static and runtime assessment`
|
||||
: index < inScope.length + deleted.length
|
||||
? `${publicSnapshotPath(manifest, deleted[index - inScope.length].path, deleted[index - inScope.length].pathId).path}: tracked source is deleted from the worktree; only retained history is available for assessment`
|
||||
: `${publicSnapshotPath(manifest, outside[index - inScope.length - deleted.length].path, outside[index - inScope.length - deleted.length].pathId).path}: outside the --paths audit scope; payload was not captured or assessed`,
|
||||
{ bytes: SNAPSHOT_DISCLOSURE_BUDGET.gaps },
|
||||
4,
|
||||
(count) => `${count} more unread or deleted in-scope inputs are not listed ${UNLISTED}`,
|
||||
(count) =>
|
||||
outside.length
|
||||
? `${count} more unread, deleted or out-of-scope inputs are not listed ${UNLISTED}`
|
||||
: `${count} more unread or deleted in-scope inputs are not listed ${UNLISTED}`,
|
||||
),
|
||||
exclusions: boundedList(
|
||||
excluded.length,
|
||||
@@ -509,18 +535,22 @@ function snapshotCoverage(manifest: Awaited<ReturnType<typeof capture>>, scope:
|
||||
(count) => `${count} more explicit exclusions are not listed ${UNLISTED}`,
|
||||
),
|
||||
evidence: [
|
||||
`${captured} sanitized execution input${captured === 1 ? '' : 's'} captured; ${excluded.length} explicit non-executable exclusion${excluded.length === 1 ? '' : 's'}; ${unread.length} unread in-scope input${unread.length === 1 ? '' : 's'}; ${deleted.length} tracked deletion${deleted.length === 1 ? '' : 's'}`,
|
||||
`${captured} sanitized execution input${captured === 1 ? '' : 's'} captured; ${excluded.length} explicit non-executable exclusion${excluded.length === 1 ? '' : 's'}; ${inScope.length} unread in-scope input${inScope.length === 1 ? '' : 's'}; ${deleted.length} tracked deletion${deleted.length === 1 ? '' : 's'}${auditPaths ? `; audit limited by --paths to ${auditPaths.join(', ')}, so ${outside.length} source file${outside.length === 1 ? ' was' : 's were'} skipped` : ''}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
function historyCoverage(status: any, scope: string): CoverageRecord {
|
||||
function historyCoverage(status: any, scope: string, auditPaths?: string[]): CoverageRecord {
|
||||
return status?.status === 'captured'
|
||||
? {
|
||||
domain: 'history-inputs',
|
||||
scope,
|
||||
status: 'assessed',
|
||||
status: auditPaths ? 'partial' : 'assessed',
|
||||
method: 'bounded helper-retained Git history',
|
||||
gaps: [],
|
||||
gaps: auditPaths
|
||||
? [
|
||||
`History outside the --paths audit scope (${auditPaths.join(', ')}) was not retained or assessed`,
|
||||
]
|
||||
: [],
|
||||
exclusions: [],
|
||||
evidence: [
|
||||
`Captured ${String(status.commits ?? 'bounded commits')} from ${String(status.range ?? 'the pinned source range')}`,
|
||||
@@ -550,11 +580,36 @@ function helperOwnedCoverage(domain: string): boolean {
|
||||
domain.startsWith('execution:')
|
||||
);
|
||||
}
|
||||
function auditPaths(raw: string): string[] {
|
||||
const values = [...new Set(raw.split(',').map((value) => value.replace(/\/+$/, '')))].sort();
|
||||
if (raw.length > 1024 || values.length > 32)
|
||||
throw new CsoError('INVALID_ARGUMENT', '--paths accepts at most 32 prefixes in 1024 characters');
|
||||
for (const value of values)
|
||||
if (
|
||||
!value ||
|
||||
value.length > 256 ||
|
||||
value.startsWith('-') ||
|
||||
value.startsWith('/') ||
|
||||
/[\\\u0000-\u001f\u007f]/.test(value) ||
|
||||
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
)
|
||||
throw new CsoError(
|
||||
'INVALID_ARGUMENT',
|
||||
`Invalid --paths entry ${JSON.stringify(value.slice(0, 80))}: use repo-relative path prefixes of at most 256 characters, without empty, '.' or '..' segments, a leading '-' or '/', backslashes, or control characters`,
|
||||
);
|
||||
else if (sanitizeHelperForJson(value) !== value)
|
||||
throw new CsoError(
|
||||
'INVALID_ARGUMENT',
|
||||
'--paths entry would be altered by report redaction; choose a shorter or broader prefix',
|
||||
);
|
||||
return values;
|
||||
}
|
||||
function parseStart(args: string[]): { repo: string; policy: RunPolicy; comparisonBase?: string } {
|
||||
const repo = callerPath(need(args, '--repo')),
|
||||
comprehensive = take(args, '--comprehensive'),
|
||||
diff = take(args, '--diff'),
|
||||
offline = take(args, '--offline');
|
||||
offline = take(args, '--offline'),
|
||||
paths = args.includes('--paths') ? auditPaths(need(args, '--paths')) : undefined;
|
||||
const scopeFlags = ['--infra', '--code', '--skills', '--supply-chain', '--owasp'].filter((f) =>
|
||||
args.includes(f),
|
||||
);
|
||||
@@ -586,6 +641,7 @@ function parseStart(args: string[]): { repo: string; policy: RunPolicy; comparis
|
||||
budgetSeconds,
|
||||
maxWorkers: 3,
|
||||
maxRepairs: 3,
|
||||
...(paths ? { paths } : {}),
|
||||
},
|
||||
...(diff || explicitBase ? { comparisonBase: base } : {}),
|
||||
};
|
||||
@@ -611,15 +667,40 @@ async function start(
|
||||
const run = newRun(repo);
|
||||
let manifest: Awaited<ReturnType<typeof capture>>;
|
||||
try {
|
||||
manifest = await capture(repo, run.dir, comparisonBase, requiredAncestor, {
|
||||
deadlineMs: createdAt.getTime() + policy.budgetSeconds * 1000 - 60_000,
|
||||
});
|
||||
manifest = await capture(
|
||||
repo,
|
||||
run.dir,
|
||||
comparisonBase,
|
||||
requiredAncestor,
|
||||
{ deadlineMs: createdAt.getTime() + policy.budgetSeconds * 1000 - 60_000 },
|
||||
policy.paths,
|
||||
);
|
||||
if (policy.diff && policy.paths) {
|
||||
const scoped = policy.paths,
|
||||
outside = (manifest.changedPaths ?? []).filter(
|
||||
(path) => !noReadPath(path) && !inAuditPaths(path, scoped),
|
||||
);
|
||||
if (outside.length)
|
||||
throw new CsoError(
|
||||
'INVALID_ARGUMENT',
|
||||
`--diff found ${outside.length} changed path${outside.length === 1 ? '' : 's'} outside --paths (${scoped.join(', ')}): ${outside
|
||||
.slice(0, 20)
|
||||
.map((path) => publicSnapshotPath(manifest, path).path)
|
||||
.join(
|
||||
', ',
|
||||
)}${outside.length > 20 ? `, and ${outside.length - 20} more` : ''}. ${parent ? 'A recheck keeps the original --diff and --paths policy, so start a new audit with them added to --paths or without --diff' : 'Add them to --paths or run without --diff'}; a changed path is never left silently out of scope.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fs.rmSync(run.dir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
const transformed = manifest.entries.filter((entry) => entry.transformation),
|
||||
deletedPaths = manifest.deletedPaths ?? [];
|
||||
const transformed = manifest.entries.filter(
|
||||
(entry) => entry.transformation && entry.transformation !== OUTSIDE_AUDIT_PATHS,
|
||||
),
|
||||
deletedPaths = (manifest.deletedPaths ?? []).filter(
|
||||
(item) => !policy.paths || inAuditPaths(item.path, policy.paths),
|
||||
);
|
||||
const report: RunReportV3 = {
|
||||
schemaVersion: 3,
|
||||
runId: run.runId,
|
||||
@@ -663,8 +744,8 @@ async function start(
|
||||
invariants: [],
|
||||
},
|
||||
coverage: [
|
||||
snapshotCoverage(manifest, policy.scope),
|
||||
historyCoverage(readJson(join(run.dir, 'history-status.json')), policy.scope),
|
||||
snapshotCoverage(manifest, policy.scope, policy.paths),
|
||||
historyCoverage(readJson(join(run.dir, 'history-status.json')), policy.scope, policy.paths),
|
||||
...planned(policy.scope),
|
||||
],
|
||||
findings: [],
|
||||
@@ -1342,6 +1423,11 @@ function submit(args: string[]) {
|
||||
'INVALID_SCHEMA',
|
||||
`Diff-scope finding root cause is outside the captured changed paths: ${sourceFinding.location.path}`,
|
||||
);
|
||||
if (report.policy.paths && !inAuditPaths(sourceLocation.path, report.policy.paths))
|
||||
throw new CsoError(
|
||||
'INVALID_SCHEMA',
|
||||
`Finding root cause is outside the --paths audit scope: ${sourceFinding.location.path}`,
|
||||
);
|
||||
const safeRaw = object(sanitizeForJson(raw), 'finding'),
|
||||
safeLocation = object(safeRaw.location, 'location');
|
||||
safeLocation.path = publicSnapshotPath(manifest, sourceLocation.path).path;
|
||||
@@ -1543,6 +1629,12 @@ async function history(args: string[]) {
|
||||
if (args.length) selected = resolveSnapshotPath(manifest, args.shift(), false, 'History path', true).path;
|
||||
if (args.length)
|
||||
throw new CsoError('INVALID_ARGUMENT', 'history accepts at most one path or opaque handle');
|
||||
const scoped = loadReport(dir).policy.paths;
|
||||
if (selected !== undefined && scoped && !inAuditPaths(selected, scoped))
|
||||
throw new CsoError(
|
||||
'INVALID_ARGUMENT',
|
||||
`History path is outside the --paths audit scope (${scoped.join(', ')}); its history was not retained`,
|
||||
);
|
||||
const status = readJson(join(dir, 'history-status.json'));
|
||||
if (status.status !== 'captured' || !fs.existsSync(join(dir, 'history.txt')))
|
||||
throw new CsoError('MISSING_INPUT', status.gap || 'Historical evidence was not retained');
|
||||
@@ -1860,6 +1952,7 @@ async function recheck(args: string[], dependencies: CsoCliDependencies) {
|
||||
'--budget',
|
||||
String(original.policy.budgetSeconds),
|
||||
...(original.policy.offline ? ['--offline'] : []),
|
||||
...(original.policy.paths ? ['--paths', original.policy.paths.join(',')] : []),
|
||||
...(original.policy.scope === 'default'
|
||||
? []
|
||||
: original.policy.scope.startsWith('domain:')
|
||||
@@ -2522,7 +2615,15 @@ async function replay(args: string[], dependencies: CsoCliDependencies) {
|
||||
const temp = newRun(source);
|
||||
temporary = temp.dir;
|
||||
workDir = temp.dir;
|
||||
manifest = await capture(source, workDir, undefined, undefined, { deadlineMs: replayDeadline });
|
||||
const scoped = loadReport(stored.dir).policy.paths;
|
||||
manifest = await capture(
|
||||
source,
|
||||
workDir,
|
||||
undefined,
|
||||
undefined,
|
||||
{ deadlineMs: replayDeadline },
|
||||
scoped === undefined ? undefined : auditPaths(Array.isArray(scoped) ? scoped.join(',') : ''),
|
||||
);
|
||||
if (
|
||||
manifest.executionHash !== bundle.requiredInputs.sourceHash ||
|
||||
manifest.originalHash !== bundle.requiredInputs.originalHash
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface RunPolicy {
|
||||
budgetSeconds: number;
|
||||
maxWorkers: 3;
|
||||
maxRepairs: 3;
|
||||
paths?: string[];
|
||||
}
|
||||
export interface RunReportV3 {
|
||||
schemaVersion: 3;
|
||||
|
||||
+66
-18
@@ -40,7 +40,8 @@ function containsDirectory(path: string, components: Set<string>, sequences: str
|
||||
parts.slice(0, -1).some((_, index) => sequence.every((part, offset) => parts[index + offset] === part)),
|
||||
);
|
||||
}
|
||||
function noReadPath(path: string): boolean {
|
||||
/** Host dependency trees: never read, copied, or counted, with or without --paths. */
|
||||
export function noReadPath(path: string): boolean {
|
||||
return containsDirectory(path, NO_READ_COMPONENTS, [['vendor', 'bundle']]);
|
||||
}
|
||||
function omittedPath(path: string): boolean {
|
||||
@@ -63,7 +64,12 @@ export function snapshotManifestCapMessage(entries: number, bytes: number): stri
|
||||
return `Snapshot manifest cap exceeded: ${entries} source entries need a ${mib(bytes)} (${bytes} byte) manifest; the snapshot manifest cap is ${mib(SNAPSHOT_MANIFEST_LIMIT)}. One entry is recorded for ${COUNTED_SOURCE}. Next step: run /cso on a smaller checkout of the code you want audited, and add your entry count to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`;
|
||||
}
|
||||
export function sourceCapMessage(bytes: number, files: number): string {
|
||||
return `64 MiB source cap exceeded: ${files} source files hold about ${mib(bytes)} (${bytes} bytes); the source admission cap is ${mib(SOURCE_LIMIT)}. The full size of ${COUNTED_SOURCE} counts, including files over 1 MiB whose payloads are withheld from the audit. Next step: run /cso on a smaller checkout of the code you want audited, and add your measured size to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`;
|
||||
return `64 MiB source cap exceeded: ${files} source files hold about ${mib(bytes)} (${bytes} bytes); the source admission cap is ${mib(SOURCE_LIMIT)}. The full size of ${COUNTED_SOURCE} inside the --paths audit scope counts (the whole repository when --paths is not set), including files over 1 MiB whose payloads are withheld from the audit. Next step: rerun with --paths set to the comma-separated repo-relative directories you want audited (narrower ones if --paths is already set); snapshot-inputs coverage is then partial, the report counts every skipped path, and the run's snapshot manifest lists each one. Do not audit a hand-trimmed copy, which hides what was skipped. Add your measured size to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`;
|
||||
}
|
||||
/** Transformation recorded for a source path outside the start --paths prefixes; its payload is never copied or assessed. */
|
||||
export const OUTSIDE_AUDIT_PATHS = 'withheld: outside the --paths audit scope';
|
||||
export function inAuditPaths(path: string, prefixes: readonly string[]): boolean {
|
||||
return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
}
|
||||
export function readSnapshotManifest(runDir: string): SnapshotManifest {
|
||||
return readJson(join(runDir, 'snapshot.json'), SNAPSHOT_MANIFEST_LIMIT) as SnapshotManifest;
|
||||
@@ -367,7 +373,12 @@ export function assertOpenedFileContained(root: string, full: string, fd: number
|
||||
)
|
||||
throw new CsoError('SNAPSHOT_RACE', 'Opened source identity changed during containment validation');
|
||||
}
|
||||
function readStable(root: string, path: string, maxBytes = MAX_OUTPUT): { data: Buffer; mode: number } {
|
||||
function readStableWith<T>(
|
||||
root: string,
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
read: (fd: number, size: number) => { bytes: number; value: T },
|
||||
): { value: T; mode: number } {
|
||||
const ancestors = directoryIdentities(root, path),
|
||||
full = containedFile(root, path),
|
||||
named = fs.lstatSync(full);
|
||||
@@ -405,12 +416,7 @@ function readStable(root: string, path: string, maxBytes = MAX_OUTPUT): { data:
|
||||
'MISSING_INPUT',
|
||||
`Source file exceeds the ${maxBytes}-byte snapshot admission limit: ${path}`,
|
||||
);
|
||||
const buffer = Buffer.alloc(Math.min(maxBytes + 1, before.size + 1));
|
||||
let bytes = 0,
|
||||
count = 0;
|
||||
while (bytes < buffer.length && (count = fs.readSync(fd, buffer, bytes, buffer.length - bytes, null)) > 0)
|
||||
bytes += count;
|
||||
const data = buffer.subarray(0, bytes),
|
||||
const { bytes, value } = read(fd, before.size),
|
||||
after = fs.fstatSync(fd),
|
||||
current = fs.lstatSync(full);
|
||||
if (
|
||||
@@ -427,11 +433,38 @@ function readStable(root: string, path: string, maxBytes = MAX_OUTPUT): { data:
|
||||
throw new CsoError('SNAPSHOT_RACE', `Source changed while reading: ${path}`);
|
||||
assertOpenedFileContained(root, full, fd, after);
|
||||
assertDirectoryIdentities(ancestors, path);
|
||||
return { data, mode: before.mode & 0o777 };
|
||||
return { value, mode: before.mode & 0o777 };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
function readStable(root: string, path: string, maxBytes = MAX_OUTPUT): { data: Buffer; mode: number } {
|
||||
const { value, mode } = readStableWith(root, path, maxBytes, (fd, size) => {
|
||||
const buffer = Buffer.alloc(Math.min(maxBytes + 1, size + 1));
|
||||
let bytes = 0,
|
||||
count = 0;
|
||||
while (bytes < buffer.length && (count = fs.readSync(fd, buffer, bytes, buffer.length - bytes, null)) > 0)
|
||||
bytes += count;
|
||||
return { bytes, value: buffer.subarray(0, bytes) };
|
||||
});
|
||||
return { data: value, mode };
|
||||
}
|
||||
/** Stream a source file's Git blob hash in bounded chunks under readStable's identity checks. */
|
||||
function gitBlobStable(root: string, path: string, objectFormat: string, tick: () => void): string {
|
||||
return readStableWith(root, path, Number.MAX_SAFE_INTEGER, (fd, size) => {
|
||||
const hash = createHash(objectFormat).update(`blob ${size}\0`),
|
||||
chunk = Buffer.alloc(Math.min(MAX_OUTPUT, size + 1));
|
||||
let bytes = 0,
|
||||
count = 0;
|
||||
while ((count = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) {
|
||||
tick();
|
||||
bytes += count;
|
||||
if (bytes > size) break;
|
||||
hash.update(chunk.subarray(0, count));
|
||||
}
|
||||
return { bytes, value: hash.digest('hex') };
|
||||
}).value;
|
||||
}
|
||||
async function resolveHeadCommit(repo: string, home: string): Promise<string | undefined> {
|
||||
try {
|
||||
return (await git(repo, ['rev-parse', '--verify', 'HEAD^{commit}'], home)).trim();
|
||||
@@ -589,6 +622,7 @@ export async function capture(
|
||||
base?: string,
|
||||
requiredAncestor?: string,
|
||||
limits: SnapshotCaptureLimits = {},
|
||||
auditPaths?: readonly string[],
|
||||
): Promise<SnapshotManifest> {
|
||||
repo = fs.realpathSync(repo);
|
||||
const state = fs.realpathSync(runDir),
|
||||
@@ -620,6 +654,10 @@ export async function capture(
|
||||
guard();
|
||||
const list = await paths(repo, home, headCommit, admission);
|
||||
guard();
|
||||
const unmatched = auditPaths?.find((prefix) => !list.some((path) => inAuditPaths(path, [prefix])));
|
||||
if (unmatched !== undefined)
|
||||
throw new CsoError('INVALID_ARGUMENT', `--paths entry matches no source file: ${unmatched}`);
|
||||
const outsideAudit = (path: string) => auditPaths !== undefined && !inAuditPaths(path, auditPaths);
|
||||
await rejectSpecialFiles(repo, home, admission);
|
||||
guard();
|
||||
const manifest: SnapshotManifest = {
|
||||
@@ -642,7 +680,7 @@ export async function capture(
|
||||
let measured = 0,
|
||||
files = 0;
|
||||
for (const candidate of list) {
|
||||
if (noReadPath(candidate)) continue;
|
||||
if (noReadPath(candidate) || outsideAudit(candidate)) continue;
|
||||
try {
|
||||
measured += fs.lstatSync(join(repo, candidate)).size;
|
||||
files++;
|
||||
@@ -662,20 +700,27 @@ export async function capture(
|
||||
}
|
||||
throw error;
|
||||
} // tracked deletions are represented by absence and the diff manifest
|
||||
// Host dependency trees aren't copied or read. Their omission is still explicit.
|
||||
if (noReadPath(path)) {
|
||||
// Host dependency trees and paths outside --paths aren't copied, assessed,
|
||||
// or counted toward the source cap. Their omission is still explicit.
|
||||
const dependency = noReadPath(path);
|
||||
if (dependency || outsideAudit(path)) {
|
||||
const stat = fs.lstatSync(containedFile(repo, path));
|
||||
if (stat.isSymbolicLink() || !stat.isFile())
|
||||
throw new CsoError('UNSAFE_PATH', `Special source input: ${path}`);
|
||||
gitModes.set(path, stat.mode & 0o111 ? '100755' : '100644');
|
||||
const reason = exclusion(path) ?? 'host dependency input';
|
||||
// A comparison base must still see every out-of-scope change, so the
|
||||
// payload is hashed for changed-path detection but never retained.
|
||||
if (!dependency && manifest.baseCommit)
|
||||
gitHashes.set(path, gitBlobStable(repo, path, objectFormat, admission.time));
|
||||
entries.push({
|
||||
path,
|
||||
pathId: snapshotPathId(repo, path),
|
||||
originalHash: 'not-read',
|
||||
bytes: stat.size,
|
||||
mode: stat.mode & 0o777,
|
||||
transformation: `excluded: ${reason}`,
|
||||
transformation: dependency
|
||||
? `excluded: ${exclusion(path) ?? 'host dependency input'}`
|
||||
: OUTSIDE_AUDIT_PATHS,
|
||||
});
|
||||
guard();
|
||||
continue;
|
||||
@@ -762,13 +807,15 @@ export async function capture(
|
||||
for (const e of entries) {
|
||||
guard();
|
||||
if (e.originalHash === 'not-read') {
|
||||
const current = fs.lstatSync(containedFile(repo, e.path));
|
||||
const current = fs.lstatSync(containedFile(repo, e.path)),
|
||||
compared = gitHashes.get(e.path);
|
||||
if (
|
||||
current.isSymbolicLink() ||
|
||||
!current.isFile() ||
|
||||
current.nlink !== 1 ||
|
||||
current.size !== e.bytes ||
|
||||
(current.mode & 0o777) !== e.mode
|
||||
(current.mode & 0o777) !== e.mode ||
|
||||
(compared !== undefined && gitBlobStable(repo, e.path, objectFormat, admission.time) !== compared)
|
||||
)
|
||||
throw new CsoError('SNAPSHOT_RACE', `${message}: ${e.path}`);
|
||||
} else {
|
||||
@@ -843,6 +890,7 @@ export async function capture(
|
||||
'-p',
|
||||
range,
|
||||
'--',
|
||||
...(auditPaths ?? []).map((prefix) => `:(literal)${prefix}`),
|
||||
],
|
||||
home,
|
||||
);
|
||||
@@ -851,7 +899,7 @@ export async function capture(
|
||||
atomicWriteSync(join(runDir, 'history.txt'), safe, { mode: 0o600 });
|
||||
writeJson(join(runDir, 'history-status.json'), {
|
||||
status: 'captured',
|
||||
range,
|
||||
range: auditPaths ? `${range}, limited to --paths ${auditPaths.join(', ')}` : range,
|
||||
commits: 'at most 100',
|
||||
bytes: Buffer.byteLength(safe),
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.111",
|
||||
"version": "1.91.112",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
// /cso --paths audit scope (field list 2, item 12): a repository over the source
|
||||
// cap can be audited by naming the subtree to read, and every skipped path stays
|
||||
// in the manifest unread and is reported as a snapshot-inputs gap.
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { OUTSIDE_AUDIT_PATHS, capture, snapshotManifestCapMessage, sourceCapMessage } from '../lib/cso/snapshot';
|
||||
import { newRun, readJson, saveReport } from '../lib/cso/state';
|
||||
import { MAX_OUTPUT, canonical, sha256, type VerificationObservation } from '../lib/cso/contracts';
|
||||
import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
|
||||
import { canonicalStartPlan, canonicalTestPlan, patchHash, verifyRepair } from '../lib/cso/verification';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const launcher = path.join(ROOT, 'bin', process.platform === 'win32' ? 'gstack-cso-launcher.exe' : 'gstack-cso-launcher');
|
||||
const MIB = 1024 * 1024;
|
||||
let root = '';
|
||||
let state = '';
|
||||
const originalHome = process.env.GSTACK_HOME;
|
||||
|
||||
function git(repo: string, ...args: string[]): string {
|
||||
const result = spawnSync('/usr/bin/git', ['-C', repo, ...args], { encoding: 'utf8', env: { HOME: root, PATH: '/usr/bin:/bin' }, timeout: 60_000 });
|
||||
if (result.status) throw new Error(result.stderr);
|
||||
return result.stdout;
|
||||
}
|
||||
function cso(repo: string, args: string[], home = state) {
|
||||
return spawnSync(launcher, args, { cwd: repo, encoding: 'utf8', env: { HOME: root, GSTACK_HOME: home, PATH: '/usr/bin:/bin' }, timeout: 120_000, maxBuffer: 64 * MIB });
|
||||
}
|
||||
function makeRepo(name: string, files: Record<string, string | Buffer>): string {
|
||||
const repo = path.join(root, name);
|
||||
fs.mkdirSync(repo);
|
||||
git(repo, 'init', '-q');
|
||||
git(repo, 'config', 'user.email', 'fixture@example.test');
|
||||
git(repo, 'config', 'user.name', 'Fixture');
|
||||
for (const [file, body] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, file), body);
|
||||
}
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-qm', 'fixture');
|
||||
return repo;
|
||||
}
|
||||
function runDir(run: { repoId: string; runId: string }): string {
|
||||
return path.join(state, 'security', 'cso', run.repoId, run.runId);
|
||||
}
|
||||
function listFiles(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir, { recursive: true, withFileTypes: true }).filter((item) => item.isFile()).map((item) => path.relative(dir, path.join(item.parentPath, item.name)).split(path.sep).join('/')).sort();
|
||||
}
|
||||
function runDirs(home: string): string[] {
|
||||
const base = path.join(home, 'security', 'cso');
|
||||
if (!fs.existsSync(base)) return [];
|
||||
return fs.readdirSync(base, { withFileTypes: true }).filter((repo) => repo.isDirectory()).flatMap((repo) => fs.readdirSync(path.join(base, repo.name), { withFileTypes: true }).filter((run) => run.isDirectory()).map((run) => `${repo.name}/${run.name}`)).sort();
|
||||
}
|
||||
function error(result: ReturnType<typeof cso>) {
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
return JSON.parse(result.stderr).error;
|
||||
}
|
||||
const USERS = 'export const tenantQuery = (id:string) => db.user.findUnique({where:{id}})\n';
|
||||
const application = { actors: ['tenant user'], assets: ['tenant records'], entrypoints: ['GET /users/:id'], tenantBoundaries: ['tenant id'], sensitiveOperations: ['record read'], invariants: ['tenant isolation'] };
|
||||
const finding = { title: 'Cross-tenant user read', rootCause: 'Tenant query omits caller tenant predicate', location: { path: 'src/core/users.ts', line: 1, symbol: 'tenantQuery' }, advisoryIds: [], severity: 'high', confidence: 'high', confidenceRationale: 'The caller-to-query trace and missing tenant predicate directly support the finding', evidence: 'supported', attackerControl: 'Authenticated caller chooses the record ID', impact: 'Another tenant record is returned', scenario: 'A tenant supplies a known record ID owned by another tenant and receives that record', trace: ['GET /users/:id', 'tenantQuery', 'findUnique by id'], references: ['src/core/users.ts:1', 'OWASP API1:2023'], recommendation: 'Bind the lookup predicate to the authenticated tenant identifier', challenge: { reviewer: 'independent-2', independent: true, mode: 'independent_agent', callers: 'Authenticated route forwards the ID', controls: 'Authentication does not bind tenant', counterevidence: 'Opaque IDs reduce guessing but do not authorize', conclusion: 'The authorization invariant is absent' } };
|
||||
|
||||
beforeAll(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-paths-'));
|
||||
state = path.join(root, 'state');
|
||||
process.env.GSTACK_HOME = state;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = originalHome;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('CSO --paths audit scope', () => {
|
||||
test('only the source cap advises --paths; the manifest cap and entry limit, which still count skipped paths, do not', async () => {
|
||||
const source = sourceCapMessage(346_199_207, 7_561);
|
||||
expect(source).toContain('rerun with --paths');
|
||||
expect(source).toContain('counts every skipped path');
|
||||
expect(source).not.toContain('smaller checkout');
|
||||
const manifest = snapshotManifestCapMessage(61_234, 17_000_000);
|
||||
expect(manifest).not.toContain('--paths');
|
||||
expect(manifest).toContain('No supported workaround raises this cap');
|
||||
|
||||
const repo = makeRepo('entry-limit-scoped', { 'src/core/users.ts': USERS, 'a.txt': 'a\n', 'b.txt': 'b\n' });
|
||||
const refused = await capture(repo, newRun(repo).dir, undefined, undefined, { maxEntries: 2 }, ['src/core']).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
);
|
||||
expect(refused?.code).toBe('MISSING_INPUT');
|
||||
expect(refused?.message).toBe('Source tree exceeds the 2-entry snapshot admission limit');
|
||||
});
|
||||
|
||||
test('a repository over the source cap is admitted with --paths, and every skipped path is disclosed unread', () => {
|
||||
const repo = makeRepo('over-cap-scoped', {
|
||||
'src/core/users.ts': USERS,
|
||||
'src/mcp/server.ts': 'export const serve = () => 1\n',
|
||||
'assets/a.bin': Buffer.alloc(40 * MIB, 1),
|
||||
'assets/b.bin': Buffer.alloc(25 * MIB, 1),
|
||||
'README.md': '# fixture\n',
|
||||
'src/core-utils/x.ts': 'export const lookalike = 1\n',
|
||||
});
|
||||
const capped = error(cso(repo, ['start', '--repo', repo, '--offline'])).message;
|
||||
expect(capped).toStartWith('64 MiB source cap exceeded:');
|
||||
expect(capped).toContain('rerun with --paths');
|
||||
|
||||
const started = cso(repo, ['start', '--repo', repo, '--offline', '--paths', 'src/mcp,src/core/,src/core']);
|
||||
expect(started.stderr).toBe('');
|
||||
expect(started.status).toBe(0);
|
||||
const run = JSON.parse(started.stdout),
|
||||
dir = runDir(run),
|
||||
report = readJson(path.join(dir, 'report.json')),
|
||||
manifest = readJson(path.join(dir, 'snapshot.json'), 16 * MIB);
|
||||
expect(report.policy.paths).toEqual(['src/core', 'src/mcp']);
|
||||
|
||||
const skipped = ['README.md', 'assets/a.bin', 'assets/b.bin', 'src/core-utils/x.ts'];
|
||||
for (const file of skipped) {
|
||||
const entry = manifest.entries.find((item: any) => item.path === file);
|
||||
expect(entry).toMatchObject({ originalHash: 'not-read', transformation: OUTSIDE_AUDIT_PATHS, bytes: fs.statSync(path.join(repo, file)).size });
|
||||
expect(entry.executionHash).toBeUndefined();
|
||||
}
|
||||
expect(OUTSIDE_AUDIT_PATHS).toStartWith('withheld:');
|
||||
expect(listFiles(path.join(dir, 'snapshot'))).toEqual(['src/core/users.ts', 'src/mcp/server.ts']);
|
||||
expect(listFiles(path.join(dir, 'readable'))).toEqual(['src/core/users.ts', 'src/mcp/server.ts']);
|
||||
|
||||
const coverage = report.coverage.find((item: any) => item.domain === 'snapshot-inputs');
|
||||
expect(coverage.status).toBe('partial');
|
||||
expect(coverage.gaps).toEqual(skipped.map((file) => `${file}: outside the --paths audit scope; payload was not captured or assessed`));
|
||||
expect(coverage.evidence).toEqual(['2 sanitized execution inputs captured; 0 explicit non-executable exclusions; 0 unread in-scope inputs; 0 tracked deletions; audit limited by --paths to src/core, src/mcp, so 4 source files were skipped']);
|
||||
expect(report.source.transformations).toEqual([]);
|
||||
const history = fs.readFileSync(path.join(dir, 'history.txt'), 'utf8');
|
||||
expect(history).toContain('src/core/users.ts');
|
||||
expect(history).not.toContain('assets/a.bin');
|
||||
expect(history).not.toContain('src/core-utils/x.ts');
|
||||
const historyCoverage = report.coverage.find((item: any) => item.domain === 'history-inputs');
|
||||
expect(historyCoverage.evidence[0]).toContain('limited to --paths src/core, src/mcp');
|
||||
expect(historyCoverage).toMatchObject({ status: 'partial', gaps: ['History outside the --paths audit scope (src/core, src/mcp) was not retained or assessed'] });
|
||||
expect(cso(repo, ['history', run.runId, 'src/core/users.ts']).stdout).toContain('src/core/users.ts');
|
||||
expect(error(cso(repo, ['history', run.runId, 'assets/a.bin']))).toEqual({ code: 'INVALID_ARGUMENT', message: 'History path is outside the --paths audit scope (src/core, src/mcp); its history was not retained' });
|
||||
|
||||
const based = cso(repo, ['start', '--repo', repo, '--offline', '--base', 'HEAD', '--paths', 'src/core,src/mcp']);
|
||||
expect(based.stderr).toBe('');
|
||||
expect(readJson(path.join(runDir(JSON.parse(based.stdout)), 'snapshot.json'), 16 * MIB).changedPaths).toEqual([]);
|
||||
|
||||
fs.rmSync(path.join(repo, 'README.md'));
|
||||
const deleted = cso(repo, ['start', '--repo', repo, '--offline', '--paths', 'src/core,src/mcp']);
|
||||
expect(deleted.stderr).toBe('');
|
||||
const deletedReport = readJson(path.join(runDir(JSON.parse(deleted.stdout)), 'report.json')),
|
||||
deletedCoverage = deletedReport.coverage.find((item: any) => item.domain === 'snapshot-inputs');
|
||||
expect(deletedReport.source.transformations).toEqual([]);
|
||||
expect(deletedCoverage.gaps).toEqual([...skipped.slice(1), 'README.md'].map((file) => `${file}: outside the --paths audit scope; payload was not captured or assessed`));
|
||||
expect(deletedCoverage.evidence[0]).toContain('0 tracked deletions; audit limited by --paths to src/core, src/mcp, so 4 source files were skipped');
|
||||
}, 180_000);
|
||||
|
||||
test('in-scope gaps are listed before skipped paths, so the bounded list never hides them', () => {
|
||||
const files: Record<string, string | Buffer> = { 'z/app.ts': USERS, 'z/blob.dat': Buffer.from('a\0b') };
|
||||
for (let i = 0; i < 3000; i++) files[`a/long-out-of-scope-directory-name/file_number_${i}.ts`] = `export const v${i} = ${i};\n`;
|
||||
const repo = makeRepo('gap-order', files),
|
||||
started = cso(repo, ['start', '--repo', repo, '--offline', '--paths', 'z']);
|
||||
expect(started.status).toBe(0);
|
||||
const coverage = readJson(path.join(runDir(JSON.parse(started.stdout)), 'report.json')).coverage.find((item: any) => item.domain === 'snapshot-inputs');
|
||||
expect(coverage.gaps[0]).toBe('z/blob.dat: in-scope source payload was unread and withheld from static and runtime assessment');
|
||||
expect(coverage.gaps.at(-1)).toMatch(/^\d+ more unread, deleted or out-of-scope inputs are not listed/);
|
||||
expect(coverage.evidence[0]).toContain('1 unread in-scope input;');
|
||||
expect(coverage.evidence[0]).toContain('so 3000 source files were skipped');
|
||||
}, 180_000);
|
||||
|
||||
test('a comparison base hashes out-of-scope files in bounded chunks', async () => {
|
||||
const repo = makeRepo('bounded-hash', { 'src/core/users.ts': USERS, 'assets/big.bin': Buffer.alloc(8 * MIB, 0x61) });
|
||||
const realAlloc = Buffer.alloc;
|
||||
let largest = 0;
|
||||
(Buffer as any).alloc = (size: number, ...rest: any[]) => {
|
||||
largest = Math.max(largest, size);
|
||||
return (realAlloc as any).call(Buffer, size, ...rest);
|
||||
};
|
||||
try {
|
||||
expect((await capture(repo, newRun(repo).dir, 'HEAD', undefined, {}, ['src/core'])).changedPaths).toEqual([]);
|
||||
expect(largest).toBeLessThanOrEqual(MAX_OUTPUT);
|
||||
fs.appendFileSync(path.join(repo, 'assets', 'big.bin'), 'x');
|
||||
expect((await capture(repo, newRun(repo).dir, 'HEAD', undefined, {}, ['src/core'])).changedPaths).toEqual(['assets/big.bin']);
|
||||
} finally {
|
||||
(Buffer as any).alloc = realAlloc;
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('capture never opens an out-of-scope payload when there is no comparison base', async () => {
|
||||
const repo = makeRepo('unreadable-outside', { 'src/core/users.ts': USERS, 'secret/blob.txt': 'outside the audit\n' });
|
||||
fs.chmodSync(path.join(repo, 'secret', 'blob.txt'), 0o000);
|
||||
try {
|
||||
await expect(capture(repo, newRun(repo).dir)).rejects.toThrow('Source could not be opened as a regular file: secret/blob.txt');
|
||||
const run = newRun(repo),
|
||||
manifest = await capture(repo, run.dir, undefined, undefined, {}, ['src/core']);
|
||||
expect(manifest.entries.find((entry) => entry.path === 'secret/blob.txt')).toMatchObject({ originalHash: 'not-read', transformation: OUTSIDE_AUDIT_PATHS, bytes: 18 });
|
||||
expect(listFiles(path.join(run.dir, 'snapshot'))).toEqual(['src/core/users.ts']);
|
||||
await expect(capture(repo, newRun(repo).dir, 'HEAD', undefined, {}, ['src/core'])).rejects.toThrow('Source could not be opened as a regular file: secret/blob.txt');
|
||||
} finally {
|
||||
fs.chmodSync(path.join(repo, 'secret', 'blob.txt'), 0o644);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid --paths values are refused and leave no run', () => {
|
||||
const repo = makeRepo('invalid-paths', { 'src/core/users.ts': USERS });
|
||||
const home = path.join(root, 'invalid-state'),
|
||||
many = Array.from({ length: 33 }, (_, i) => `src/p${i}`).join(',');
|
||||
for (const value of ['', ',', '../src', 'src/../x', '/src', '-src', 'src//core', 'src/./core', 'src\\core', 'src\ncore', many, 'a'.repeat(257), 'src,'.repeat(260)]) {
|
||||
const refused = error(cso(repo, ['start', '--repo', repo, '--offline', '--paths', value], home));
|
||||
expect(refused.code).toBe('INVALID_ARGUMENT');
|
||||
}
|
||||
const unmatched = error(cso(repo, ['start', '--repo', repo, '--offline', '--paths', 'src/core,src/typo'], home));
|
||||
expect(unmatched).toEqual({ code: 'INVALID_ARGUMENT', message: '--paths entry matches no source file: src/typo' });
|
||||
expect(error(cso(repo, ['start', '--repo', repo, '--paths'], home)).message).toBe('--paths requires a value');
|
||||
expect(error(cso(repo, ['start', '--repo', repo, '--offline', '--paths', 'rootfs/home/user/app'], home))).toEqual({ code: 'INVALID_ARGUMENT', message: '--paths entry would be altered by report redaction; choose a shorter or broader prefix' });
|
||||
expect(error(cso(repo, ['start', '--repo', repo, '--offline', '--paths', 'mirror/actions/runs/42'], home)).message).toBe('--paths entry would be altered by report redaction; choose a shorter or broader prefix');
|
||||
expect(runDirs(home)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test('--diff refuses changed paths outside --paths and compares unchanged ones without reporting them', () => {
|
||||
const repo = makeRepo('diff-scope', { 'src/core/users.ts': USERS, 'src/other/a.ts': 'export const a = 1\n', 'docs/b.md': 'b\n', 'AGENTS.md': '# agents\n', 'node_modules/dep/index.js': 'module.exports = 1\n' }),
|
||||
base = git(repo, 'rev-parse', 'HEAD').trim(),
|
||||
home = path.join(root, 'diff-state'),
|
||||
start = () => cso(repo, ['start', '--repo', repo, '--offline', '--diff', '--base', base, '--paths', 'src/core'], home);
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'src/core/users.ts'), USERS + '// changed\n');
|
||||
fs.writeFileSync(path.join(repo, 'node_modules/dep/index.js'), 'module.exports = 2\n');
|
||||
const accepted = start();
|
||||
expect(accepted.stderr).toBe('');
|
||||
expect(accepted.status).toBe(0);
|
||||
const run = JSON.parse(accepted.stdout),
|
||||
manifest = readJson(path.join(home, 'security', 'cso', run.repoId, run.runId, 'snapshot.json'), 16 * MIB);
|
||||
expect(manifest.changedPaths).toEqual(['node_modules/dep/index.js', 'src/core/users.ts']);
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), '# agents, edited\n');
|
||||
fs.writeFileSync(path.join(repo, '.npmrc'), 'registry=https://registry.example.test/\n');
|
||||
expect(error(start()).message).toContain('--diff found 2 changed paths outside --paths (src/core): .npmrc, AGENTS.md.');
|
||||
git(repo, 'checkout', '--', 'AGENTS.md');
|
||||
fs.rmSync(path.join(repo, '.npmrc'));
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'src/other/a.ts'), 'export const a = 2\n');
|
||||
const modified = error(start());
|
||||
expect(modified.code).toBe('INVALID_ARGUMENT');
|
||||
expect(modified.message).toContain('--diff found 1 changed path outside --paths (src/core): src/other/a.ts.');
|
||||
git(repo, 'checkout', '--', 'src/other/a.ts');
|
||||
|
||||
fs.rmSync(path.join(repo, 'docs/b.md'));
|
||||
fs.writeFileSync(path.join(repo, 'new.ts'), 'export const n = 1\n');
|
||||
const added = error(start());
|
||||
expect(added.message).toContain('--diff found 2 changed paths outside --paths (src/core): docs/b.md, new.ts.');
|
||||
fs.mkdirSync(path.join(repo, 'many'));
|
||||
for (let i = 0; i < 21; i++) fs.writeFileSync(path.join(repo, 'many', `f${String(i).padStart(2, '0')}.ts`), `export const f = ${i}\n`);
|
||||
expect(error(start()).message).toContain('--diff found 23 changed paths outside --paths (src/core): docs/b.md, many/f00.ts');
|
||||
expect(error(start()).message).toContain(', and 3 more. Add them to --paths or run without --diff');
|
||||
expect(runDirs(home)).toEqual([`${run.repoId}/${run.runId}`]);
|
||||
}, 180_000);
|
||||
|
||||
test('recheck rebuilds the original --paths scope', () => {
|
||||
const repo = makeRepo('recheck-scope', { 'src/core/users.ts': USERS, 'tools/build.ts': 'export const build = 1\n' }),
|
||||
started = cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline', '--paths', 'src/core']);
|
||||
expect(started.status).toBe(0);
|
||||
const run = JSON.parse(started.stdout),
|
||||
dir = runDir(run),
|
||||
initial = readJson(path.join(dir, 'report.json')),
|
||||
evidence = path.join(root, 'recheck-evidence.json');
|
||||
fs.writeFileSync(evidence, JSON.stringify({ application, findings: [finding], coverage: initial.coverage.filter((item: any) => !['snapshot-inputs', 'history-inputs'].includes(item.domain)).map((item: any) => ({ ...item, status: 'assessed', method: 'fresh caller and boundary trace', gaps: [], evidence: ['current captured source'] })), gaps: [] }));
|
||||
const outside = path.join(root, 'outside-finding.json');
|
||||
fs.writeFileSync(outside, JSON.stringify({ findings: [{ ...finding, location: { ...finding.location, path: 'tools/build.ts' }, references: ['tools/build.ts:1'] }] }));
|
||||
const refusedFinding = error(cso(repo, ['submit', run.runId, outside]));
|
||||
expect(refusedFinding).toEqual({ code: 'INVALID_SCHEMA', message: 'Finding root cause is outside the --paths audit scope: tools/build.ts' });
|
||||
expect(cso(repo, ['submit', run.runId, evidence]).status).toBe(0);
|
||||
expect(cso(repo, ['finish', run.runId]).status).toBe(0);
|
||||
const final = readJson(path.join(dir, 'report.json'));
|
||||
expect(final.findings).toHaveLength(1);
|
||||
const rechecked = cso(repo, ['recheck', final.findings[0].id, '--repo', repo]);
|
||||
expect(rechecked.stderr).toBe('');
|
||||
expect(rechecked.status).toBe(0);
|
||||
const childRun = JSON.parse(rechecked.stdout),
|
||||
child = readJson(path.join(runDir({ ...childRun, repoId: run.repoId }), 'report.json'));
|
||||
expect(child.policy.paths).toEqual(['src/core']);
|
||||
expect(child.coverage.find((item: any) => item.domain === 'snapshot-inputs').gaps).toEqual(['tools/build.ts: outside the --paths audit scope; payload was not captured or assessed']);
|
||||
const claim = path.join(root, 'withheld-claim.json');
|
||||
fs.writeFileSync(claim, JSON.stringify({ recheck: { findingId: final.findings[0].id, outcome: 'resolved', rootCause: finding.rootCause, evidence: [{ kind: 'caller', path: 'tools/build.ts', line: 1, observation: 'A caller outside the audit scope' }, { kind: 'security_boundary', path: 'src/core/users.ts', line: 1, observation: 'Fresh boundary trace' }] } }));
|
||||
expect(error(cso(repo, ['submit', childRun.runId, claim]))).toEqual({ code: 'INVALID_SCHEMA', message: 'Recheck evidence must reference freshly captured readable source' });
|
||||
const base = git(repo, 'rev-parse', 'HEAD').trim();
|
||||
fs.appendFileSync(path.join(repo, 'src/core/users.ts'), '// reviewed change\n');
|
||||
const diffStarted = cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline', '--diff', '--base', base, '--paths', 'src/core']);
|
||||
expect(diffStarted.stderr).toBe('');
|
||||
const diffRun = JSON.parse(diffStarted.stdout),
|
||||
diffCoverage = readJson(path.join(runDir(diffRun), 'report.json')).coverage.filter((item: any) => !['snapshot-inputs', 'history-inputs'].includes(item.domain));
|
||||
fs.writeFileSync(evidence, JSON.stringify({ application, findings: [finding], coverage: diffCoverage.map((item: any) => ({ ...item, status: 'assessed', method: 'fresh caller and boundary trace', gaps: [], evidence: ['current captured source'] })), gaps: [] }));
|
||||
expect(cso(repo, ['submit', diffRun.runId, evidence]).status).toBe(0);
|
||||
expect(cso(repo, ['finish', diffRun.runId]).status).toBe(0);
|
||||
fs.writeFileSync(path.join(repo, 'tools/build.ts'), 'export const build = 2\n');
|
||||
expect(error(cso(repo, ['recheck', final.findings[0].id, '--run', diffRun.runId, '--repo', repo])).message).toContain('--diff found 1 changed path outside --paths (src/core): tools/build.ts. A recheck keeps the original --diff and --paths policy, so start a new audit with them added to --paths or without --diff;');
|
||||
git(repo, 'checkout', '--', '.');
|
||||
git(repo, 'mv', 'src/core', 'src/kernel');
|
||||
git(repo, 'commit', '-qm', 'rename the audited directory');
|
||||
expect(error(cso(repo, ['recheck', final.findings[0].id, '--run', run.runId, '--repo', repo]))).toEqual({ code: 'INVALID_ARGUMENT', message: '--paths entry matches no source file: src/core' });
|
||||
}, 180_000);
|
||||
|
||||
test.skipIf(process.platform === 'win32')('replay from supplied source recaptures with the original --paths scope', async () => {
|
||||
const repo = makeRepo('replay-scope', {
|
||||
'package.json': JSON.stringify({ name: 'cso-paths-replay', version: '1.0.0', private: true, scripts: { start: 'node app.js', test: 'node --test' } }) + '\n',
|
||||
'package-lock.json': JSON.stringify({ name: 'cso-paths-replay', version: '1.0.0', lockfileVersion: 3, requires: true, packages: { '': { name: 'cso-paths-replay', version: '1.0.0' } } }) + '\n',
|
||||
'app.js': 'module.exports = "vulnerable"\n',
|
||||
'app.test.js': "const test=require('node:test');test('legitimate control remains available',()=>{});\n",
|
||||
'docs/notes.md': 'outside the audit\n',
|
||||
});
|
||||
const started = cso(repo, ['start', '--repo', repo, '--comprehensive', '--offline', '--paths', 'app.js,app.test.js,package.json,package-lock.json']);
|
||||
expect(started.stderr).toBe('');
|
||||
const run = JSON.parse(started.stdout),
|
||||
dir = runDir(run),
|
||||
snapshot = path.join(dir, 'snapshot'),
|
||||
manifest = readJson(path.join(dir, 'snapshot.json'), 16 * MIB);
|
||||
expect(manifest.entries.find((entry: any) => entry.path === 'docs/notes.md').transformation).toBe(OUTSIDE_AUDIT_PATHS);
|
||||
const recordFinding = { ...finding, location: { path: 'app.js', line: 1, symbol: 'recordLookup' }, references: ['app.js:1', 'OWASP API1:2023'] },
|
||||
submission = path.join(root, 'replay-finding.json');
|
||||
fs.writeFileSync(submission, JSON.stringify({ findings: [recordFinding] }));
|
||||
expect(cso(repo, ['submit', run.runId, submission]).status).toBe(0);
|
||||
const report = readJson(path.join(dir, 'report.json')),
|
||||
start = canonicalStartPlan(snapshot, 'node', 3456),
|
||||
tests = canonicalTestPlan(snapshot, 'node'),
|
||||
arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
|
||||
const request: any = { findingId: report.findings[0].id, runtimeProfile: `node-24.4.0-${arch}`, port: 3456, start: start.command, legitimate: [{ name: 'legitimate control', path: '/control', method: 'GET', expected: { status: 200, includes: 'CONTROL_OK' } }], security: { name: 'unauthorized record is denied', path: '/records/other', method: 'GET', expected: { status: 403, includes: 'DENIED' }, vulnerable: { status: 200, includes: 'RECORD' } }, existingTests: tests.commands, fixtures: {}, boundaryFiles: ['app.js'], testFiles: tests.files, changes: [{ path: 'app.js', beforeSha256: sha256(fs.readFileSync(path.join(snapshot, 'app.js'))), after: 'module.exports = "fixed"\n', effect: 'source' }], review: { reviewer: 'independent-reviewer', independent: true, rootCauseRepaired: true, featurePreserved: true, boundaryMocks: false, rationale: 'The caller predicate is added while the legitimate control and existing test remain unchanged.', reviewedPatchHash: '' } };
|
||||
request.review.reviewedPatchHash = patchHash(request);
|
||||
const tap = 'TAP version 13\n# Subtest: legitimate control remains available\nok 1 - legitimate control remains available\n ---\n duration_ms: 1\n ...\n1..1\n# tests 1\n# suites 0\n# pass 1\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n',
|
||||
runtime: any = { id: request.runtimeProfile, stack: 'node', platform: `linux/${arch}`, image: `ghcr.io/garrytan/gstack/cso-staging/node-${arch}@sha256:${'b'.repeat(64)}` },
|
||||
executor = {
|
||||
observe: async (_source: string, phase: 'before' | 'after', received: any, _runtime: any, _verifier: any, _work: string, _control: string, _execution: any, evidence: any, witness: any) => {
|
||||
const observation: VerificationObservation = { booted: true, legitimate: true, security: phase === 'before' ? 'intended_failure' : 'pass', existingTests: false, output: `external ${phase} assertions passed`, inputHash: '' },
|
||||
receipt = await witness.attest(observation, [{ command: received.existingTests[0], code: 0, output: tap, minimumPassingTests: evidence.minimumPassingTests[0] }]);
|
||||
return { observation: { ...observation, existingTests: receipt.diagnosticTestsPassed, inputHash: witness.binding.harnessHash }, witness: receipt };
|
||||
},
|
||||
};
|
||||
const verified = await verifyRepair({ runId: run.runId, runDir: dir, manifest, rawRequest: request, runtime, verifier: runtime, policyHash: ISOLATION_POLICY_HASH, auditPolicyHash: sha256(canonical(report.policy)), archives: [], executor } as any);
|
||||
report.findings[0].reproduction = 'reproduced';
|
||||
report.findings[0].repair = 'runtime_tested';
|
||||
report.findings[0].verificationId = verified.bundle.id;
|
||||
report.findings[0].verificationAssurance = { assertions: 'authenticated_out_of_process', testCompletion: 'self_reported', review: 'self_attested' };
|
||||
saveReport(dir, report);
|
||||
fs.rmSync(snapshot, { recursive: true, force: true });
|
||||
fs.rmSync(path.join(dir, 'readable'), { recursive: true, force: true });
|
||||
const supplied = cso(repo, ['replay', verified.bundle.id, '--source', repo]);
|
||||
expect(supplied.status).not.toBe(0);
|
||||
expect(supplied.stderr).not.toContain('does not match the bundle input hashes');
|
||||
expect(supplied.stderr).toContain('MISSING_QUALIFIED_RUNTIME');
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in new issue
Block a user